feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs
This commit is contained in:
+1063
-6
@@ -22,7 +22,7 @@ CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
from .read import preprocess, extract_data_division, extract_procedure_division
|
from .read import preprocess, extract_data_division, extract_procedure_division
|
||||||
from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements
|
from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements, scan_all_file_directions
|
||||||
from .read import parse_file_control, resolve_sql_includes, strip_exec_sql_from_data_div
|
from .read import parse_file_control, resolve_sql_includes, strip_exec_sql_from_data_div
|
||||||
from .core import build_branch_tree, classify_field_roles, _init_child_names, sql_register_virtual_fields, _find_multi_write_fds
|
from .core import build_branch_tree, classify_field_roles, _init_child_names, sql_register_virtual_fields, _find_multi_write_fds
|
||||||
from .cond import parse_single_condition, is_field, collect_leaves
|
from .cond import parse_single_condition, is_field, collect_leaves
|
||||||
@@ -291,6 +291,1027 @@ def _inject_empty_emp_rec(records, fields):
|
|||||||
logger.info(f" injected empty-EMP-ID record at position 0")
|
logger.info(f" injected empty-EMP-ID record at position 0")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_match_key_field(base: str) -> bool:
|
||||||
|
"""跨文件照合キー判定:EMP-ID / DATE に加え、末尾が -NO/-CODE のキー
|
||||||
|
(OFFICE-NO / INSURER-CODE 等)も対象。非キー(NAME/KANA/FILLER/SEX/
|
||||||
|
TYPE/STATUS/PREF)は除外。共通ロジック、プログラム名ハードコードなし。"""
|
||||||
|
u = base.upper()
|
||||||
|
if any(kw in u for kw in ('NAME', 'KANA', 'FILLER', 'SEX', 'TYPE',
|
||||||
|
'STATUS', 'PREF', 'ADDR', 'DETAIL', 'REMARK')):
|
||||||
|
return False
|
||||||
|
if 'EMP-ID' in u or 'DATE' in u:
|
||||||
|
return True
|
||||||
|
if u.endswith('-NO') or u.endswith('-CODE') or u.endswith('-ID'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _common_prefix(names):
|
||||||
|
"""计算一组字段名的最长公共前缀(限定以字母开头)。
|
||||||
|
|
||||||
|
用于从 FD 的实际字段名推导字段前缀。COPY REPLACING 程序(如 KIN07DAI 的
|
||||||
|
SWEMP-ID/SWDATE… → 'SW')与字段名即 FD 前缀的程序(如 JIN01EMP-ID → 'JIN01')
|
||||||
|
均适用。
|
||||||
|
"""
|
||||||
|
cands = [n for n in names if n and n[0].isalpha()]
|
||||||
|
if not cands:
|
||||||
|
return ''
|
||||||
|
base = cands[0]
|
||||||
|
for n in cands[1:]:
|
||||||
|
i = 0
|
||||||
|
while i < len(base) and i < len(n) and base[i] == n[i]:
|
||||||
|
i += 1
|
||||||
|
base = base[:i]
|
||||||
|
if not base:
|
||||||
|
break
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _fd_leaf_field_names(fd_name, file_sec, data_fields):
|
||||||
|
"""返回指定 FD 的叶子字段名列表(排除 88 级 / FILLER / 组项)。"""
|
||||||
|
names = []
|
||||||
|
for rn in file_sec.get(fd_name, []):
|
||||||
|
for child in _init_child_names(rn, data_fields):
|
||||||
|
names.append(child)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_multi_file_keys(records, kept_path_cons, data_fields, assignments, file_sec, term_types=None, open_dir=None):
|
||||||
|
"""协调跨文件匹配程序的キー值,为部分记录设置相同キー以触发照合路径。
|
||||||
|
|
||||||
|
数据驱动方式:扫描记录中的跨文件キー字段,强制部分记录キー一致。
|
||||||
|
通用实现:从 data_fields 中发现不同 FD 的キー类字段(EMP-ID/DATE),
|
||||||
|
配对后修改记录値使キー一致。不依赖路径约束。
|
||||||
|
|
||||||
|
前缀推导:从 FD 的叶子字段名求公共前缀(SW/SR/SL 或 JIN01 等),
|
||||||
|
而非从 01 记录名推导(R01INNREC→'R01')。COPY REPLACING 场景下
|
||||||
|
记录名前缀与字段前缀不一致,旧逻辑导致键对匹配不到。
|
||||||
|
"""
|
||||||
|
if not records or not file_sec or len(file_sec) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 1: 自动发现 INPUT FD 的字段前缀和キー字段
|
||||||
|
# 保留 file_sec 顺序(源文件顺序):第一个 INPUT FD 即主驱动(master)
|
||||||
|
fd_prefixes = []
|
||||||
|
seen_prefix = set()
|
||||||
|
for fd_name in file_sec:
|
||||||
|
# 只保留 INPUT FD(open_dir 有方向信息时按方向过滤,避免把输出 FD
|
||||||
|
# 的キー也纳入协调导致输出记录被误改)
|
||||||
|
if open_dir:
|
||||||
|
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
|
||||||
|
if direction not in ('INPUT', 'I-O'):
|
||||||
|
continue
|
||||||
|
prefix = _common_prefix(_fd_leaf_field_names(fd_name, file_sec, data_fields))
|
||||||
|
if prefix and prefix not in seen_prefix:
|
||||||
|
seen_prefix.add(prefix)
|
||||||
|
fd_prefixes.append(prefix)
|
||||||
|
|
||||||
|
if len(fd_prefixes) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3+ 输入 FD:使用前置匹配序列(全匹配 + 各单文件匹配 + 判别字段注入)
|
||||||
|
if len(fd_prefixes) > 2:
|
||||||
|
_coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 按前缀分组找到キー字段(EMP-ID / DATE / 事務所・保険者コード類)。
|
||||||
|
# 复用共享实现 _find_key_pairs_inner(精确 / WORK- / EMP 族 / 后缀匹配),
|
||||||
|
# 避免两处维护同一逻辑。
|
||||||
|
key_pairs = _find_key_pairs_inner(fd_prefixes[0], fd_prefixes[1], data_fields)
|
||||||
|
if not key_pairs:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: 修改部分记录的キー值
|
||||||
|
modified = 0
|
||||||
|
max_modify = max(1, min(5, len(records) // 5))
|
||||||
|
modified_indices = []
|
||||||
|
for i, rec in enumerate(records):
|
||||||
|
if modified >= max_modify:
|
||||||
|
break
|
||||||
|
# 确认双方都有非空値
|
||||||
|
has_both = True
|
||||||
|
for fa, fb in key_pairs:
|
||||||
|
va = str(rec.get(fa, '')).strip()
|
||||||
|
vb = str(rec.get(fb, '')).strip()
|
||||||
|
if not va or not vb:
|
||||||
|
has_both = False
|
||||||
|
break
|
||||||
|
if not has_both:
|
||||||
|
continue
|
||||||
|
# 已有部分キー一致时跳过(避免破坏已有匹配)
|
||||||
|
already_matched = all(str(rec.get(fa, '')).strip() == str(rec.get(fb, '')).strip()
|
||||||
|
for fa, fb in key_pairs)
|
||||||
|
if already_matched:
|
||||||
|
continue
|
||||||
|
# 将 prefix_a 的キー设为 prefix_b 的値
|
||||||
|
for fa, fb in key_pairs:
|
||||||
|
rec[fa] = rec[fb]
|
||||||
|
modified_indices.append(i)
|
||||||
|
modified += 1
|
||||||
|
|
||||||
|
# Step 3: 创建重复 R02 キー,触发 2030PRIOSOR(マッチング選定の優先度判定)
|
||||||
|
# 需要连续 2 条 R02 记录拥有相同キー,使 2020MATCHSOR 的内部循环进入 ELSE 分支
|
||||||
|
# 跳过 'abend' 记录(不会写入二进制文件),只考虑 'normal'/'eof' 记录
|
||||||
|
if len(modified_indices) >= 2 and term_types:
|
||||||
|
normal_pairs = []
|
||||||
|
for idx in range(1, len(modified_indices)):
|
||||||
|
i_prev = modified_indices[idx - 1]
|
||||||
|
i_next = modified_indices[idx]
|
||||||
|
t_prev = term_types[i_prev] if i_prev < len(term_types) else 'normal'
|
||||||
|
t_next = term_types[i_next] if i_next < len(term_types) else 'normal'
|
||||||
|
if t_prev not in ('abend',) and t_next not in ('abend',):
|
||||||
|
normal_pairs.append((i_prev, i_next))
|
||||||
|
if normal_pairs:
|
||||||
|
i_prev, i_next = normal_pairs[0]
|
||||||
|
for _, fb in key_pairs:
|
||||||
|
records[i_next][fb] = records[i_prev][fb]
|
||||||
|
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR)")
|
||||||
|
elif len(modified_indices) >= 2 and not term_types:
|
||||||
|
# 无 term_types 时使用第一个 pair
|
||||||
|
i_prev, i_next = modified_indices[0], modified_indices[1]
|
||||||
|
for _, fb in key_pairs:
|
||||||
|
records[i_next][fb] = records[i_prev][fb]
|
||||||
|
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR,无 term_types)")
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
logger.info(f" 跨文件键值协同: 将 {modified} 条记录的キー设为一致 (前缀 {fd_prefixes[0]}={fd_prefixes[1]})")
|
||||||
|
else:
|
||||||
|
# 回退:强制修改第一条有两方都非空キーの记录
|
||||||
|
for i, rec in enumerate(records):
|
||||||
|
has_both = all(str(rec.get(fa, '')).strip() and str(rec.get(fb, '')).strip()
|
||||||
|
for fa, fb in key_pairs)
|
||||||
|
if has_both:
|
||||||
|
for fa, fb in key_pairs:
|
||||||
|
rec[fa] = rec[fb]
|
||||||
|
logger.info(f" 跨文件键值协同(回退): 记录 {i} キー已设为一致")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Step 4: 末条 normal 记录を表内键に整列(キーブレイク集約の最终组输出分支可达)
|
||||||
|
# 最終社员工组は EOF 後の終了処理(`IF WRK-EMP-EVAL-CNT > ZERO ...`)で出力されるため、
|
||||||
|
# 末条记录が主表键と一致しないと最終组输出分支不可達。先頭のみ整列したままだと
|
||||||
|
# 最終组に照合データが無い。首条 R02 键(必ず内部表ロード範囲内)を使用する。
|
||||||
|
if records and key_pairs:
|
||||||
|
table_key = str(records[0].get(key_pairs[0][1], '')).strip()
|
||||||
|
if table_key:
|
||||||
|
for i in range(len(records) - 1, -1, -1):
|
||||||
|
rec = records[i]
|
||||||
|
if term_types and i < len(term_types) and term_types[i] == 'abend':
|
||||||
|
continue
|
||||||
|
if str(rec.get(key_pairs[0][1], '')).strip():
|
||||||
|
for fa, fb in key_pairs:
|
||||||
|
rec[fa] = table_key
|
||||||
|
logger.info(f" 最終キーブレイク组照合: 记录 {i} 明细键={table_key}")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_range_matching(records, data_fields, file_sec, open_dir, term_types=None):
|
||||||
|
"""金额-区间对齐:让部分记录的 *MONTHLY-AMOUNT 落入另一输入 FD 的
|
||||||
|
*MONTHLY-FROM/TO 区间。
|
||||||
|
|
||||||
|
适用:N:1 キーブレイク集約後、平均標準報酬月額を GRAD 区间でマッチング
|
||||||
|
するプログラム(SHA05TWN 等)。生成データの金額と区間は独立 idx で合成
|
||||||
|
され数量级がずれるため、AVG が区间に命中せず GRADE 判定/出力経路が
|
||||||
|
不可達になる。本関数は部分记录の金額を区間中値に揃え、恰 3 条同 EMP-ID
|
||||||
|
の记录も注入して MOVE 'B' 分支を覆う。
|
||||||
|
|
||||||
|
通用実装:フィールド名パターン(MONTHLY-AMOUNT / MONTHLY-FROM /
|
||||||
|
MONTHLY-TO)+入力 FD 判定(open_dir)で検出。プログラム名ハードコードなし。
|
||||||
|
"""
|
||||||
|
if not records or not data_fields or not file_sec:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 输入 FD 名 → 前缀
|
||||||
|
input_fd_names = []
|
||||||
|
if open_dir:
|
||||||
|
for fd_name in file_sec:
|
||||||
|
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
|
||||||
|
if direction in ('INPUT', 'I-O'):
|
||||||
|
input_fd_names.append(fd_name)
|
||||||
|
if not input_fd_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 输入 FD 前缀集合(从 file_sec 的 FD 记录名提取前缀)
|
||||||
|
input_prefixes = set()
|
||||||
|
for fd_name in input_fd_names:
|
||||||
|
for rn in file_sec.get(fd_name, []):
|
||||||
|
m = re.match(r'^([A-Z]+\d*)', rn)
|
||||||
|
if m:
|
||||||
|
input_prefixes.add(m.group(1))
|
||||||
|
|
||||||
|
# 收集输入 FD 的字段(按前缀,从 data_fields 扫描实际字段名)
|
||||||
|
amount_fields = [] # (fd_prefix, field_name)
|
||||||
|
from_fields = []
|
||||||
|
to_fields = []
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic'):
|
||||||
|
continue
|
||||||
|
name = f['name']
|
||||||
|
if f.get('is_88'):
|
||||||
|
continue
|
||||||
|
prefix = None
|
||||||
|
for p in input_prefixes:
|
||||||
|
if name.startswith(p):
|
||||||
|
prefix = p
|
||||||
|
break
|
||||||
|
if prefix is None:
|
||||||
|
continue
|
||||||
|
base = name[len(prefix):].lstrip('-')
|
||||||
|
u = base.upper()
|
||||||
|
if 'MONTHLY-AMOUNT' in u:
|
||||||
|
amount_fields.append((prefix, name))
|
||||||
|
elif u.endswith('MONTHLY-FROM'):
|
||||||
|
from_fields.append((prefix, name))
|
||||||
|
elif u.endswith('MONTHLY-TO'):
|
||||||
|
to_fields.append((prefix, name))
|
||||||
|
|
||||||
|
if not amount_fields or not from_fields or not to_fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 找跨 FD 前缀的 金额↔区间 配对(金额在 R01,区间在 R02)
|
||||||
|
range_pair = None
|
||||||
|
for af_pref, af in amount_fields:
|
||||||
|
for ff_pref, ff in from_fields:
|
||||||
|
for tf_pref, tf in to_fields:
|
||||||
|
if af_pref != ff_pref and ff_pref == tf_pref:
|
||||||
|
range_pair = (af_pref, af, ff_pref, ff, tf)
|
||||||
|
break
|
||||||
|
if range_pair:
|
||||||
|
break
|
||||||
|
if range_pair:
|
||||||
|
break
|
||||||
|
if not range_pair:
|
||||||
|
return
|
||||||
|
|
||||||
|
af_pref, af, ff_pref, ff, tf = range_pair
|
||||||
|
logger.info(f" 金额-区间对齐: 金额 {af} ({af_pref}) ↔ 区间 {ff}/{tf} ({ff_pref})")
|
||||||
|
|
||||||
|
# EMP-ID 字段(金额所在 FD 前缀)
|
||||||
|
emp_field = None
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic'):
|
||||||
|
continue
|
||||||
|
nm = f['name']
|
||||||
|
if nm.startswith(af_pref) and 'EMP-ID' in nm.upper():
|
||||||
|
emp_field = nm
|
||||||
|
break
|
||||||
|
if not emp_field:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 从记录中提取有效区间(取第一条有值的 R02 区间)
|
||||||
|
from_val = None
|
||||||
|
to_val = None
|
||||||
|
for rec in records:
|
||||||
|
fv = str(rec.get(ff, '')).strip()
|
||||||
|
tv = str(rec.get(tf, '')).strip()
|
||||||
|
if fv and tv and fv.isdigit() and tv.isdigit():
|
||||||
|
from_val = int(fv)
|
||||||
|
to_val = int(tv)
|
||||||
|
break
|
||||||
|
if from_val is None:
|
||||||
|
return
|
||||||
|
mid_val = (from_val + to_val) // 2
|
||||||
|
mid_str = str(mid_val).zfill(9)
|
||||||
|
|
||||||
|
# 选定 normal 记录(非 abend)
|
||||||
|
normal_indices = [i for i in range(len(records))
|
||||||
|
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
|
||||||
|
if len(normal_indices) < 4:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 前 4 条记录:金额设为区间中值,前 4 条同 EMP-ID(→ COUNT>=4 → MOVE 'A')
|
||||||
|
hit_indices = normal_indices[:4]
|
||||||
|
hit_emp = str(records[hit_indices[0]].get(emp_field, '')).strip() or 'A0000001'
|
||||||
|
for i in hit_indices:
|
||||||
|
rec = records[i]
|
||||||
|
rec[af] = mid_str
|
||||||
|
rec[emp_field] = hit_emp
|
||||||
|
logger.info(f" 金额-区间对齐: {len(hit_indices)} 条记录金额→{mid_str}(区间命中,COUNT>=4 → TYPE='A')")
|
||||||
|
|
||||||
|
# 2) 构造恰 3 条同 EMP-ID(→ COUNT=3 → MOVE 'B'):从 remaining 取 3 条
|
||||||
|
remaining = [i for i in normal_indices if i not in set(hit_indices)]
|
||||||
|
if len(remaining) >= 3:
|
||||||
|
b_indices = remaining[:3]
|
||||||
|
b_emp = str(records[b_indices[0]].get(emp_field, '')).strip() or 'B0000002'
|
||||||
|
for i in b_indices:
|
||||||
|
rec = records[i]
|
||||||
|
rec[af] = mid_str
|
||||||
|
rec[emp_field] = b_emp
|
||||||
|
logger.info(f" 金额-区间对齐: {len(b_indices)} 条记录同 EMP-ID={b_emp}(COUNT=3 → TYPE='B')")
|
||||||
|
|
||||||
|
# 3) 其余记录保留原值:区间外 / 记录数<3 → NO-GRADE / INVALID 错误分支维持
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_read_into_records(records, assignments, fields, file_sec):
|
||||||
|
"""不透明 FD + READ INTO:将求解出的 INTO 组子字段值序列化回 FD 记录字节。
|
||||||
|
|
||||||
|
问题:FD 记录不透明(如 R01INNREC PIC X(080) 无子字段)且程序用
|
||||||
|
`READ FD INTO WS-GRP` 读取时,Pass 3.5 中 fd_children 为空 → 求解出的
|
||||||
|
WRK-* 值从未写回 FD 记录字节 → 物理输入文件全是占位符、key 全同,
|
||||||
|
新 key 分支(flush + WRITE)不可达。
|
||||||
|
|
||||||
|
本函数:
|
||||||
|
1) 找到 READ INTO 赋值(fd 名, 目标组)
|
||||||
|
2) 按目标组子字段 PIC 长度计算布局偏移
|
||||||
|
3) 将求解出的子字段值按偏移 pack 回 FD 记录字节
|
||||||
|
4) 保证相邻记录 key 既有相同又有差异(同 key 累加 + 新 key flush 两路径)
|
||||||
|
"""
|
||||||
|
if not records or not assignments or not file_sec:
|
||||||
|
return records
|
||||||
|
|
||||||
|
name_to_field = {f['name']: f for f in fields}
|
||||||
|
|
||||||
|
read_intos = []
|
||||||
|
for tgt, asgn_list in assignments.items():
|
||||||
|
for a in asgn_list:
|
||||||
|
if isinstance(a, dict) and a.get('type') == 'read_into':
|
||||||
|
read_intos.append((a.get('file', ''), tgt))
|
||||||
|
if not read_intos:
|
||||||
|
return records
|
||||||
|
|
||||||
|
for fname, tgt in read_intos:
|
||||||
|
if fname not in file_sec or not file_sec[fname]:
|
||||||
|
continue
|
||||||
|
fd_rec = file_sec[fname][0]
|
||||||
|
fd_field = name_to_field.get(fd_rec)
|
||||||
|
if not fd_field:
|
||||||
|
continue
|
||||||
|
if _init_child_names(fd_rec, fields):
|
||||||
|
continue
|
||||||
|
pi = fd_field.get('pic_info', {})
|
||||||
|
fd_len = pi.get('length') or (pi.get('digits', 0) + pi.get('decimal', 0)) or 0
|
||||||
|
if fd_len <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ws_children = _init_child_names(tgt, fields)
|
||||||
|
layout = []
|
||||||
|
offset = 0
|
||||||
|
for c in ws_children:
|
||||||
|
cf = name_to_field.get(c)
|
||||||
|
if not cf:
|
||||||
|
continue
|
||||||
|
cpi = cf.get('pic_info', {})
|
||||||
|
if not cpi or cpi.get('type') == 'unknown':
|
||||||
|
continue
|
||||||
|
clen = cpi.get('length') or (cpi.get('digits', 0) + cpi.get('decimal', 0)) or 0
|
||||||
|
if clen <= 0:
|
||||||
|
continue
|
||||||
|
layout.append((c, offset, clen, cpi))
|
||||||
|
offset += clen
|
||||||
|
if not layout:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key_children = []
|
||||||
|
for c, off, clen, cpi in layout:
|
||||||
|
base = c[4:] if c.startswith('WRK-') else c
|
||||||
|
if c.startswith('WRK-') and ('WRK-PREV-' + base) in name_to_field:
|
||||||
|
key_children.append((c, off, clen, cpi))
|
||||||
|
if not key_children:
|
||||||
|
for c, off, clen, cpi in layout:
|
||||||
|
if 'EMP-ID' in c or 'APPL-DATE' in c:
|
||||||
|
key_children.append((c, off, clen, cpi))
|
||||||
|
|
||||||
|
if key_children and len(records) >= 2:
|
||||||
|
kc, koff, klen, kpi = key_children[0]
|
||||||
|
keys = [str(r.get(kc, '')).strip() for r in records]
|
||||||
|
has_same = any(keys[i] == keys[i + 1] for i in range(len(keys) - 1))
|
||||||
|
has_diff = any(keys[i] != keys[i + 1] for i in range(len(keys) - 1))
|
||||||
|
if not has_diff:
|
||||||
|
records[-1][kc] = _inc_str(str(records[-1].get(kc, '')), klen)
|
||||||
|
logger.info(f" READ INTO {tgt}: 注入 key 差异(新 key flush 路径)")
|
||||||
|
if not has_same:
|
||||||
|
records[1][kc] = records[0][kc]
|
||||||
|
logger.info(f" READ INTO {tgt}: 注入相同 key(累加路径)")
|
||||||
|
|
||||||
|
for rec in records:
|
||||||
|
buf = bytearray(b' ' * fd_len)
|
||||||
|
for c, off, clen, cpi in layout:
|
||||||
|
if off >= fd_len:
|
||||||
|
continue
|
||||||
|
val = str(rec.get(c, ''))
|
||||||
|
if cpi.get('type') in ('numeric', 'numeric-edited'):
|
||||||
|
seg = val.zfill(clen)
|
||||||
|
else:
|
||||||
|
seg = val.ljust(clen)[:clen]
|
||||||
|
seg_bytes = seg.encode('utf-8', 'replace')[:clen]
|
||||||
|
end = min(off + len(seg_bytes), fd_len)
|
||||||
|
buf[off:end] = seg_bytes[:end - off]
|
||||||
|
rec[fd_rec] = buf.decode('utf-8', 'replace')
|
||||||
|
|
||||||
|
logger.info(f" READ INTO 序列化: {fd_rec} ← {tgt} ({len(layout)} 字段)")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_merge_input_records(records, fields, file_sec, fd_fields, proc_div, term_types=None):
|
||||||
|
"""MERGE 程序:为 USING 输入文件注入可执行合并逻辑的 S/B 记录。
|
||||||
|
|
||||||
|
根因:MERGE ... USING 的输入文件从不 OPEN,原管道不识别为输入 → 无输入
|
||||||
|
数据 → 运行时 MERGE 读到空文件 → 2000MRGOUTSOR 全部不执行。
|
||||||
|
|
||||||
|
本函数:
|
||||||
|
1) 解析 MERGE 语句的 USING 文件列表(SALARY-FILE/BONUS-FILE)
|
||||||
|
2) 识别各 USING 文件 FD 的 REC-TYPE / EMP-ID / PAY-AMOUNT / GROSS 字段
|
||||||
|
3) 从 OUTPUT PROCEDURE 的 `IF <rec-type> = <CONST>` 解析判别常量值
|
||||||
|
(如 CNS-PAY-TYPE-SALARY='S'),其余 USING 文件取相异值
|
||||||
|
4) 注入一条含全部 USING 文件字段的记录(同 EMP-ID 跨文件匹配,
|
||||||
|
触发 2000MRGOUTSOR 同社員統合 + REC-TYPE T/F 分支)
|
||||||
|
|
||||||
|
通用实现:不依赖程序名/字段名硬编码,对所有 MERGE/SORT 程序适用。
|
||||||
|
"""
|
||||||
|
if not records or not proc_div or not file_sec or len(fd_fields) < 2:
|
||||||
|
return records
|
||||||
|
_KEYWORDS = {'USING', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT', 'PROCEDURE'}
|
||||||
|
|
||||||
|
def _resolve_const(name, fields_list):
|
||||||
|
for f in fields_list:
|
||||||
|
if f.get('name') == name and f.get('value') is not None:
|
||||||
|
return str(f['value']).strip("'\"").strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_field(fd_name, suffix):
|
||||||
|
for fname in fd_fields.get(fd_name, []):
|
||||||
|
if fname.upper().endswith(suffix.upper()) and fname not in (fd_name,):
|
||||||
|
return fname
|
||||||
|
return None
|
||||||
|
|
||||||
|
injected = False
|
||||||
|
for m in re.finditer(
|
||||||
|
r'\bMERGE\s+\w[\w-]*\s+.*?\bUSING\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.',
|
||||||
|
proc_div, re.IGNORECASE | re.DOTALL
|
||||||
|
):
|
||||||
|
stmt = m.group(0)
|
||||||
|
files_raw = m.group(1)
|
||||||
|
using = [f.upper() for f in re.findall(r'\b(\w[\w-]*)\b', files_raw)
|
||||||
|
if f.upper() not in _KEYWORDS and f.upper() in file_sec]
|
||||||
|
if len(using) < 2:
|
||||||
|
continue
|
||||||
|
out_sec = None
|
||||||
|
om = re.search(r'\bOUTPUT\s+PROCEDURE\s+(\w[\w-]*)', stmt, re.IGNORECASE)
|
||||||
|
if om:
|
||||||
|
out_sec = om.group(1).upper()
|
||||||
|
|
||||||
|
# 判别字段(REC-TYPE)与判别常量值
|
||||||
|
type_field = None
|
||||||
|
const_val = None
|
||||||
|
if out_sec:
|
||||||
|
for f in fields:
|
||||||
|
fn = f.get('name', '')
|
||||||
|
if fn.upper().endswith('REC-TYPE'):
|
||||||
|
type_field = fn
|
||||||
|
break
|
||||||
|
if type_field:
|
||||||
|
cm = re.search(
|
||||||
|
r'\bIF\s+%s\b\s*(?:=|NOT\s*=|<>|>|<|>=|<=)\s*(\w[\w-]*)' % re.escape(type_field),
|
||||||
|
proc_div, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if not cm:
|
||||||
|
cm = re.search(
|
||||||
|
r'\b%s\s*(?:=|NOT\s*=|<>)\s*(\w[\w-]*)' % re.escape(type_field),
|
||||||
|
proc_div, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if cm:
|
||||||
|
const_val = _resolve_const(cm.group(1).upper(), fields)
|
||||||
|
if const_val is None:
|
||||||
|
const_val = 'S' # 通用回退:第一个 USING 文件用 'S'
|
||||||
|
# 第二文件判别值:优先取 *PAY-TYPE-BONUS* 常量,否则取与 const_val 相异值
|
||||||
|
other_val = None
|
||||||
|
for f in fields:
|
||||||
|
fn = f.get('name', '')
|
||||||
|
if 'PAY-TYPE' in fn.upper() and 'BONUS' in fn.upper() and f.get('value') is not None:
|
||||||
|
other_val = str(f['value']).strip("'\"").strip()
|
||||||
|
break
|
||||||
|
if other_val is None:
|
||||||
|
other_val = (chr(ord(const_val) + 1) if len(const_val) == 1 else 'B')
|
||||||
|
if other_val == const_val:
|
||||||
|
other_val = 'B'
|
||||||
|
|
||||||
|
# 各 USING 文件的字段映射
|
||||||
|
emp_fields, pay_fields, gross_fields, type_fields = [], [], [], []
|
||||||
|
for fd in using:
|
||||||
|
emp_fields.append(_find_field(fd, 'EMP-ID'))
|
||||||
|
pay_fields.append(_find_field(fd, 'PAY-AMOUNT'))
|
||||||
|
gross_fields.append(_find_field(fd, 'GROSS'))
|
||||||
|
type_fields.append(_find_field(fd, 'REC-TYPE'))
|
||||||
|
if any(f is None for f in emp_fields) or any(f is None for f in type_fields):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 每条记录:所有 USING 文件字段都设置,同 EMP-ID,REC-TYPE 按文件相异。
|
||||||
|
# 注:output_input_files 对次要 FD 会丢弃末尾 2 条 → 注入记录必须放最前。
|
||||||
|
base = dict(records[0]) if records else {}
|
||||||
|
n = len(using)
|
||||||
|
merge_recs = []
|
||||||
|
for emp_i in range(2):
|
||||||
|
rec = dict(base)
|
||||||
|
emp_id = f'A{emp_i + 1:07d}'
|
||||||
|
for i, fd in enumerate(using):
|
||||||
|
type_val = const_val if i == 0 else other_val
|
||||||
|
rec[emp_fields[i]] = emp_id
|
||||||
|
rec[type_fields[i]] = type_val
|
||||||
|
if pay_fields[i]:
|
||||||
|
rec[pay_fields[i]] = str(100000 + emp_i * 1000 + i * 100).zfill(9)
|
||||||
|
if gross_fields[i]:
|
||||||
|
rec[gross_fields[i]] = str(200000 + emp_i * 1000 + i * 100).zfill(9)
|
||||||
|
merge_recs.append(rec)
|
||||||
|
records[0:0] = merge_recs
|
||||||
|
if term_types is not None:
|
||||||
|
term_types[0:0] = ['normal', 'normal']
|
||||||
|
injected = True
|
||||||
|
logger.info(f" 注入 MERGE 输入记录: USING={using} REC-TYPE={const_val}/{other_val} 共 2 条(同 EMP-ID 跨文件,置顶)")
|
||||||
|
break
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_end_of_page_records(records, full_source, proc_div, file_sec, open_dir, term_types=None):
|
||||||
|
"""LINAGE 分页(WRITE ... AT END-OF-PAGE)プログラム:补充分页体积记录。
|
||||||
|
|
||||||
|
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 END-OF-PAGE(分页)分支,
|
||||||
|
需要足够多的明细记录触发 LINAGE 分页才能覆盖(例:JIN04PRT 输入仅 1 条 →
|
||||||
|
AT END-OF-PAGE 换页重打分支不可达,gcov 仅 80/96)。
|
||||||
|
|
||||||
|
本函数(通用・程序名硬编码なし):
|
||||||
|
1) PROCEDURE DIVISION 中检测 `WRITE ... AT END-OF-PAGE`
|
||||||
|
2) 从全量源码解析输出 FD 的 LINAGE 总行数 - TOP - BOTTOM 作为正文容量
|
||||||
|
(解析失败回退 60)
|
||||||
|
3) 将一条有效 normal 记录复制补足到「容量+1」条,使最后一条 WRITE
|
||||||
|
触发 END-OF-PAGE
|
||||||
|
"""
|
||||||
|
if not records or not proc_div or not full_source:
|
||||||
|
return records
|
||||||
|
if not re.search(r'\bEND-OF-PAGE\b', proc_div, re.IGNORECASE):
|
||||||
|
return records
|
||||||
|
|
||||||
|
# LINAGE 容量:LINAGE IS n LINES(TOP t / BOTTOM b)→ 正文行数
|
||||||
|
capacity = None
|
||||||
|
m = re.search(
|
||||||
|
r'LINAGE\s+IS\s+(\d+)\s+LINES([^.]*?)(?=\.)',
|
||||||
|
full_source, re.IGNORECASE | re.DOTALL)
|
||||||
|
if m:
|
||||||
|
total = int(m.group(1))
|
||||||
|
rest = m.group(2)
|
||||||
|
top_m = re.search(r'\bTOP\s+(\d+)', rest, re.IGNORECASE)
|
||||||
|
bot_m = re.search(r'\bBOTTOM\s+(\d+)', rest, re.IGNORECASE)
|
||||||
|
top = int(top_m.group(1)) if top_m else 0
|
||||||
|
bottom = int(bot_m.group(1)) if bot_m else 0
|
||||||
|
capacity = total - top - bottom
|
||||||
|
if not capacity or capacity <= 0:
|
||||||
|
capacity = 60
|
||||||
|
target = capacity + 1
|
||||||
|
|
||||||
|
def _is_normal(i):
|
||||||
|
if term_types is None or i >= len(term_types):
|
||||||
|
return True
|
||||||
|
return term_types[i] != 'abend'
|
||||||
|
|
||||||
|
template = None
|
||||||
|
for i in range(len(records) - 1, -1, -1):
|
||||||
|
if _is_normal(i) and records[i]:
|
||||||
|
template = dict(records[i])
|
||||||
|
break
|
||||||
|
if template is None:
|
||||||
|
return records
|
||||||
|
|
||||||
|
current = sum(1 for i in range(len(records)) if _is_normal(i))
|
||||||
|
if current >= target:
|
||||||
|
return records
|
||||||
|
need = target - current
|
||||||
|
for _ in range(need):
|
||||||
|
records.append(dict(template))
|
||||||
|
if term_types is not None:
|
||||||
|
term_types.extend(['normal'] * need)
|
||||||
|
logger.info(
|
||||||
|
f" END-OF-PAGE 分页注入: 复制 {need} 条记录至 {len(records)} 条"
|
||||||
|
f"(LINAGE 容量 {capacity},触发分页)")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_sort_limit_records(records, proc_div, fields, term_types=None):
|
||||||
|
"""SORT INPUT 计数守卫(RELEASE + IF <计数器> <= <上限>):补充分页体积记录。
|
||||||
|
|
||||||
|
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 SORT INPUT PROCEDURE 中的
|
||||||
|
记录数上限守卫(如 `ADD 1 TO WRK-SORT-CNT ... IF WRK-SORT-CNT <=
|
||||||
|
CNS-MAX-SORT-REC(999) RELEASE ... ELSE 上限エラー`),需超过上限条数的输入
|
||||||
|
记录才能覆盖 ELSE(SORT LIMIT OVER)分支(例:JIN06SRT 输入仅 6 条,上限 999)。
|
||||||
|
|
||||||
|
本函数(通用・程序名硬编码なし):
|
||||||
|
1) PROC 中检测 SORT + RELEASE + 同一段落内 `IF <counter> (<=|<) <limit>`
|
||||||
|
2) 解析 <limit> 的常量值(字段 VALUE 子句或字面值)
|
||||||
|
3) 将一条 valid normal 记录复制到「上限+1」条,使最后一条 READ 触发上限分支
|
||||||
|
"""
|
||||||
|
if not records or not proc_div:
|
||||||
|
return records
|
||||||
|
up = proc_div.upper()
|
||||||
|
if 'SORT' not in up or 'RELEASE' not in up:
|
||||||
|
return records
|
||||||
|
|
||||||
|
# 计数守卫:IF <counter> (<=|<) <limit>(同段落内必须有 RELEASE)
|
||||||
|
m = re.search(r'\bIF\s+(\w[\w-]*)\s*(?:<=|<)\s*(\w[\w-]*)\b', proc_div, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return records
|
||||||
|
after = proc_div[m.start():]
|
||||||
|
para_end = re.search(r'\n\s*\w[\w-]*-EXT\.', after)
|
||||||
|
scope = after[:para_end.start()] if para_end else after
|
||||||
|
if 'RELEASE' not in scope.upper():
|
||||||
|
return records
|
||||||
|
|
||||||
|
# 解析上限值:字段 VALUE 子句或字面值
|
||||||
|
limit_ref = m.group(2)
|
||||||
|
limit = None
|
||||||
|
raw = str(limit_ref).strip().strip("'\"").strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
limit = int(raw)
|
||||||
|
else:
|
||||||
|
for f in (fields or []):
|
||||||
|
if f.get('name') == raw and f.get('value') is not None:
|
||||||
|
try:
|
||||||
|
limit = int(str(f['value']).strip("'\"").strip())
|
||||||
|
except ValueError:
|
||||||
|
limit = None
|
||||||
|
break
|
||||||
|
if limit is None or limit <= 0:
|
||||||
|
return records
|
||||||
|
target = limit + 1
|
||||||
|
|
||||||
|
def _is_normal(i):
|
||||||
|
if term_types is None or i >= len(term_types):
|
||||||
|
return True
|
||||||
|
return term_types[i] != 'abend'
|
||||||
|
|
||||||
|
template = None
|
||||||
|
for i in range(len(records) - 1, -1, -1):
|
||||||
|
if _is_normal(i) and records[i]:
|
||||||
|
template = dict(records[i])
|
||||||
|
break
|
||||||
|
if template is None:
|
||||||
|
return records
|
||||||
|
|
||||||
|
current = sum(1 for i in range(len(records)) if _is_normal(i))
|
||||||
|
if current >= target:
|
||||||
|
return records
|
||||||
|
need = target - current
|
||||||
|
for _ in range(need):
|
||||||
|
records.append(dict(template))
|
||||||
|
if term_types is not None:
|
||||||
|
term_types.extend(['normal'] * need)
|
||||||
|
logger.info(
|
||||||
|
f" SORT 上限体积注入: 复制 {need} 条记录至 {len(records)} 条"
|
||||||
|
f"(上限 {limit},触发 SORT LIMIT OVER)")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_sub_input_files(main_source, source_dir, outdir):
|
||||||
|
"""子程序输入供给:为 CALL 目标子程序的 INPUT FD 生成输入文件。
|
||||||
|
|
||||||
|
根因:V3 只为主程序生成输入文件。テストドライバ(主程序无 FILE 节)调用
|
||||||
|
读取文件的自程序时(如 JIN08TST → JIN07SUB 读 JIN07R01),自程序 OPEN
|
||||||
|
输入文件失败(status 35)→ ABEND → 后续决策点不可达。
|
||||||
|
|
||||||
|
本函数(通用・程序名硬编码なし):
|
||||||
|
1) 主程序 PROC 中 `CALL 'NAME'` 收集子程序名
|
||||||
|
2) 在 source_dir 的 sub/ 找到子程序源码
|
||||||
|
3) 解析其 FILE-CONTROL 的 INPUT/I-O FD(ASSIGN 名・记录布局)
|
||||||
|
4) 按子程序 FD 布局写一条基础记录到 outdir/<assign>
|
||||||
|
|
||||||
|
返回已生成的 assign 名列表。
|
||||||
|
"""
|
||||||
|
src_dir = Path(source_dir)
|
||||||
|
sub_dir = src_dir.parent / 'sub'
|
||||||
|
if not sub_dir.is_dir():
|
||||||
|
sub_dir = src_dir / 'sub'
|
||||||
|
cpy_dir = src_dir.parent / 'cpy'
|
||||||
|
if not cpy_dir.is_dir():
|
||||||
|
cpy_dir = src_dir / 'cpy'
|
||||||
|
|
||||||
|
calls = set()
|
||||||
|
for m in re.finditer(r"\bCALL\s+['\"](\w+)['\"]", main_source, re.IGNORECASE):
|
||||||
|
calls.add(m.group(1).upper())
|
||||||
|
if not calls:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from .flatfile import analyze_fd_layout, write_flat_file
|
||||||
|
written = []
|
||||||
|
for sub_name in sorted(calls):
|
||||||
|
sp = sub_dir / f"{sub_name}.cbl"
|
||||||
|
if not sp.exists():
|
||||||
|
continue
|
||||||
|
sub_src = sp.read_text(encoding='utf-8')
|
||||||
|
try:
|
||||||
|
sub_src = resolve_copybooks(
|
||||||
|
sub_src, str(sub_dir),
|
||||||
|
extra_search_paths=[str(cpy_dir)] if cpy_dir.is_dir() else None)
|
||||||
|
layouts = analyze_fd_layout(
|
||||||
|
sub_src,
|
||||||
|
copybook_dirs=[str(cpy_dir)] if cpy_dir.is_dir() else None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" 子程序 {sub_name} 布局解析失败: {e}")
|
||||||
|
continue
|
||||||
|
for assign, layout in layouts.items():
|
||||||
|
if layout.get('direction') not in ('INPUT', 'I-O'):
|
||||||
|
continue
|
||||||
|
base = {}
|
||||||
|
for rec in layout.get('records', []):
|
||||||
|
for f in rec.get('fields', []):
|
||||||
|
pi = f.get('pic_info') or {}
|
||||||
|
if pi.get('type') == 'numeric':
|
||||||
|
ln = (pi.get('digits', 0) + pi.get('decimal', 0)) or 1
|
||||||
|
base[f['name']] = '0' * ln
|
||||||
|
else:
|
||||||
|
ln = f.get('length') or pi.get('length') or 1
|
||||||
|
base[f['name']] = 'A' * ln
|
||||||
|
outpath = Path(outdir) / assign
|
||||||
|
outpath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
write_flat_file([base], layout, outpath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" 子程序 {sub_name} 输入文件写入失败: {e}")
|
||||||
|
continue
|
||||||
|
rec_len = layout.get('records', [{}])[0].get('record_length', 0)
|
||||||
|
written.append(assign)
|
||||||
|
logger.info(f" 子程序输入供给: {sub_name} → {assign}(记录 {rec_len}B)")
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types=None):
|
||||||
|
"""构造多文件照合程序的前置匹配序列(通用,不依赖程序名)。
|
||||||
|
|
||||||
|
fd_prefixes = [主FD字段前缀, 明细FD字段前缀1, ...](如 SW/SR/SL)。
|
||||||
|
在记录头部构造单调递增键序列,使 merge-join 指针自然对齐:
|
||||||
|
- rec0: 主 + 全部明细键一致 → 全匹配(照合ループ + 判别 EVALUATE 全 WHEN)
|
||||||
|
- rec(k): 主 + 第 k 个明细键一致、其余不同 → 单明细匹配(各照合组合)
|
||||||
|
判别字段(*LEAVE-TYPE)所在明细构造同键重复块:
|
||||||
|
- 块1: 短时长 + 判别值 01-04 → 全匹配内循环消费(EVALUATE 全 WHEN)
|
||||||
|
- 块2: 长时长(0600-2200)+ 判别值 01-04 → 单明细匹配内循环消费,
|
||||||
|
覆盖时长上限检查 T 分支与午餐重叠判定。
|
||||||
|
"""
|
||||||
|
master = fd_prefixes[0]
|
||||||
|
details = fd_prefixes[1:]
|
||||||
|
if not details:
|
||||||
|
return
|
||||||
|
|
||||||
|
normal_indices = [i for i in range(len(records))
|
||||||
|
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
|
||||||
|
|
||||||
|
dpairs = {}
|
||||||
|
for d in details:
|
||||||
|
pairs = _find_key_pairs_inner(master, d, data_fields)
|
||||||
|
if pairs:
|
||||||
|
dpairs[d] = pairs
|
||||||
|
if not dpairs:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 判别字段:取"最后一个"含 *LEAVE-TYPE 的明细 FD(深层休暇展开文件)。
|
||||||
|
# 注意 R02 打刻文件也可能含 LEAVE-TYPE 字段(如 SRLEAVE-TYPE),但
|
||||||
|
# 内循环 EVALUATE 判别的是休暇展开明细(R03),故从后向前选。
|
||||||
|
disc_detail = None
|
||||||
|
disc_field = None
|
||||||
|
for d in reversed(details):
|
||||||
|
if d not in dpairs:
|
||||||
|
continue
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic'):
|
||||||
|
continue
|
||||||
|
nm = f.get('name', '')
|
||||||
|
if nm.startswith(d) and 'LEAVE-TYPE' in nm.upper():
|
||||||
|
disc_detail, disc_field = d, nm
|
||||||
|
break
|
||||||
|
if disc_detail:
|
||||||
|
break
|
||||||
|
disc_values = ['01', '02', '03', '04', '99'] if disc_field else []
|
||||||
|
m = len(disc_values) if disc_field else 2
|
||||||
|
|
||||||
|
n = len(details)
|
||||||
|
match_cnt = n + 1
|
||||||
|
need = max(match_cnt, m) + m
|
||||||
|
if len(normal_indices) < max(need, 6):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _synth(idx):
|
||||||
|
# 合成键:EMP-ID 'A0000001' 起递增、日期 20000101 起递增(字典序单调递增)
|
||||||
|
return f'A{idx + 1:07d}', f'{20000101 + idx:08d}'
|
||||||
|
|
||||||
|
def _apply(rec, prefix, pairs, idx):
|
||||||
|
emp, date = _synth(idx)
|
||||||
|
for fa, fb in pairs:
|
||||||
|
f = fa if prefix == master else fb
|
||||||
|
val = date if 'DATE' in f.upper() else emp
|
||||||
|
rec[f] = val
|
||||||
|
|
||||||
|
p = normal_indices
|
||||||
|
# rec0: 全匹配(主 + 全部明细键 = K0)
|
||||||
|
rec = records[p[0]]
|
||||||
|
for d, pairs in dpairs.items():
|
||||||
|
_apply(rec, master, pairs, 0)
|
||||||
|
_apply(rec, d, pairs, 0)
|
||||||
|
# rec1..n: 单明细匹配(主 + 该明细 = K(k+1),其余明细用大偏移避免误配)
|
||||||
|
for k, d in enumerate(details):
|
||||||
|
if k + 1 >= len(p):
|
||||||
|
continue
|
||||||
|
rec = records[p[k + 1]]
|
||||||
|
_apply(rec, master, dpairs[d], k + 1)
|
||||||
|
_apply(rec, d, dpairs[d], k + 1)
|
||||||
|
for d2, pairs2 in dpairs.items():
|
||||||
|
if d2 != d:
|
||||||
|
_apply(rec, d2, pairs2, k + 1 + 50)
|
||||||
|
|
||||||
|
if disc_detail and disc_field:
|
||||||
|
pairs = dpairs[disc_detail]
|
||||||
|
# 块1:位置 p[0..m-1],键 = K0,短时长 + 判别值
|
||||||
|
for j in range(m):
|
||||||
|
if j >= len(p):
|
||||||
|
break
|
||||||
|
rec = records[p[j]]
|
||||||
|
_apply(rec, disc_detail, pairs, 0)
|
||||||
|
rec[disc_field] = disc_values[j]
|
||||||
|
# 块2:位置 p[max(match_cnt,m)..+m],键 = K(disc_idx),长时长 + 判别值
|
||||||
|
disc_idx = details.index(disc_detail) + 1
|
||||||
|
b2_start = max(match_cnt, m)
|
||||||
|
st_f = en_f = None
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic'):
|
||||||
|
continue
|
||||||
|
nm = f.get('name', '')
|
||||||
|
if nm.startswith(disc_detail):
|
||||||
|
u = nm.upper()
|
||||||
|
if 'START-TIME' in u:
|
||||||
|
st_f = nm
|
||||||
|
elif 'END-TIME' in u:
|
||||||
|
en_f = nm
|
||||||
|
for j in range(m):
|
||||||
|
idx = b2_start + j
|
||||||
|
if idx >= len(p):
|
||||||
|
break
|
||||||
|
rec = records[p[idx]]
|
||||||
|
_apply(rec, disc_detail, pairs, disc_idx)
|
||||||
|
rec[disc_field] = disc_values[j]
|
||||||
|
if st_f and en_f:
|
||||||
|
rec[st_f] = '0600'
|
||||||
|
rec[en_f] = '2200'
|
||||||
|
logger.info(
|
||||||
|
f" 前置匹配序列: 全匹配+{n}单明细匹配, {disc_detail} 判别块 {disc_values} "
|
||||||
|
f"(长时长 0600-2200 覆盖上限/午餐判定)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 无判别字段:仍制造同键重复(触发明细内循环)
|
||||||
|
d0, pairs0 = next(iter(dpairs.items()))
|
||||||
|
if len(p) >= 2:
|
||||||
|
rec0, rec1 = records[p[0]], records[p[1]]
|
||||||
|
_apply(rec0, master, pairs0, 0)
|
||||||
|
_apply(rec0, d0, pairs0, 0)
|
||||||
|
_apply(rec1, master, pairs0, 0)
|
||||||
|
_apply(rec1, d0, pairs0, 0)
|
||||||
|
logger.info(f" 前置匹配序列: 全匹配+{n}单明细匹配 ({d0} 同键重复)")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _find_key_pairs_inner(prefix_a, prefix_b, data_fields):
|
||||||
|
"""Find BEST matching key field pairs between two FD prefixes.
|
||||||
|
Isolated helper for _coordinate_tertiary_fd.
|
||||||
|
"""
|
||||||
|
a_candidates = {}
|
||||||
|
b_candidates = {}
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic'):
|
||||||
|
continue
|
||||||
|
name = f['name']
|
||||||
|
if name.startswith(prefix_a):
|
||||||
|
base = name[len(prefix_a):].lstrip('-')
|
||||||
|
if _is_match_key_field(base):
|
||||||
|
a_candidates[name] = base
|
||||||
|
elif name.startswith(prefix_b):
|
||||||
|
base = name[len(prefix_b):].lstrip('-')
|
||||||
|
if _is_match_key_field(base):
|
||||||
|
b_candidates[name] = base
|
||||||
|
pairs = []
|
||||||
|
used_b = set()
|
||||||
|
for fa, ba in sorted(a_candidates.items()):
|
||||||
|
for fb, bb in sorted(b_candidates.items()):
|
||||||
|
if fb in used_b:
|
||||||
|
continue
|
||||||
|
if ba == bb or ba.replace('WORK-', '') == bb or ba == bb.replace('WORK-', ''):
|
||||||
|
pairs.append((fa, fb))
|
||||||
|
used_b.add(fb)
|
||||||
|
break
|
||||||
|
for fa, ba in sorted(a_candidates.items()):
|
||||||
|
if any(p[0] == fa for p in pairs):
|
||||||
|
continue
|
||||||
|
ua = ba.upper()
|
||||||
|
if 'EMP' not in ua:
|
||||||
|
continue
|
||||||
|
for fb, bb in sorted(b_candidates.items()):
|
||||||
|
if fb in used_b:
|
||||||
|
continue
|
||||||
|
ub = bb.upper()
|
||||||
|
if 'EMP' in ub:
|
||||||
|
pairs.append((fa, fb))
|
||||||
|
used_b.add(fb)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Suffix tier: 明细键 base 是主表键 base 的真后缀(如 SKILL-CODE ⊂
|
||||||
|
# MST-SKILL-CODE)且 key 类别一致(同为 -CODE/-ID/-NO/DATE/EMP-ID)时配对。
|
||||||
|
# 通用实现、无程序名硬编码,覆盖"主表键带限定词(MST- 等)"的照合形态。
|
||||||
|
def _same_key_class(x, y):
|
||||||
|
ux, uy = x.upper(), y.upper()
|
||||||
|
for cls in ('EMP-ID', '-CODE', '-ID', '-NO', 'DATE'):
|
||||||
|
if cls in ux and cls in uy:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for fa, ba in sorted(a_candidates.items()):
|
||||||
|
if any(p[0] == fa for p in pairs):
|
||||||
|
continue
|
||||||
|
for fb, bb in sorted(b_candidates.items()):
|
||||||
|
if fb in used_b:
|
||||||
|
continue
|
||||||
|
if (len(ba) >= 3 and len(bb) >= 3 and ba != bb
|
||||||
|
and _same_key_class(ba, bb)
|
||||||
|
and (bb.upper().endswith(ba.upper())
|
||||||
|
or ba.upper().endswith(bb.upper()))):
|
||||||
|
pairs.append((fa, fb))
|
||||||
|
used_b.add(fb)
|
||||||
|
break
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_leave_type_scenarios(records, term_types):
|
||||||
|
"""Inject R02LEAVE-TYPE / R02APPL-ID 覆盖 2030PRIOSOR 所有 WHEN + IF-ELSE 分支 + WHEN OTHER。
|
||||||
|
|
||||||
|
在 _coordinate_multi_file_keys 之后运行, 取 16 条连续 normal 记录
|
||||||
|
(1 anchor + 14 scenario + 1 WHEN OTHER), 设其 R01/R02 キー相同, 然后依次设
|
||||||
|
R02LEAVE-TYPE 为 '99'→'04'→'04'→'03'→'03'→'04'→'02'→'02'→
|
||||||
|
'03'→'04'→'01'→'01'→'02'→'03'→'04'→'99',
|
||||||
|
配合 APPL-ID 递减, 以单一 2020MATCHSOR 调用遍历 EVALUATE 内部所有路径。
|
||||||
|
"""
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 守卫:仅当记录中存在 LEAVE-TYPE 字段(2030PRIOSOR 系)时执行。
|
||||||
|
# 避免对无 LEAVE-TYPE 的 N:1 集約程序(如 SHA05TWN)产生副作用——
|
||||||
|
# 该函数原会无差别把前 16 条 normal 记录的 EMP-ID 设为同一值。
|
||||||
|
if not any('LEAVE-TYPE' in str(k).upper() for k in records[0]):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 跳过 abend 记录, 找连续 16 条 normal (1 anchor + 14 scenario + 1 OTHER)
|
||||||
|
needed = 16
|
||||||
|
normal_indices = []
|
||||||
|
for i in range(len(records)):
|
||||||
|
if term_types and i < len(term_types) and term_types[i] == 'abend':
|
||||||
|
continue
|
||||||
|
normal_indices.append(i)
|
||||||
|
if len(normal_indices) >= needed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(normal_indices) < needed:
|
||||||
|
logger.warning(f" 休暇種別覆盖: normal 记录不足 ({len(normal_indices)}/{needed}), 跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor_idx = normal_indices[0]
|
||||||
|
anchor = records[anchor_idx]
|
||||||
|
common_emp = str(anchor.get('R02EMP-ID', '')).strip() or 'E0000999'
|
||||||
|
common_date = str(anchor.get('R02DATE', '')).strip() or '20991231'
|
||||||
|
|
||||||
|
# 所有记录设相同キー
|
||||||
|
for idx in normal_indices:
|
||||||
|
rec = records[idx]
|
||||||
|
rec['R02EMP-ID'] = common_emp
|
||||||
|
rec['R02DATE'] = common_date
|
||||||
|
rec['R01EMP-ID'] = common_emp
|
||||||
|
rec['R01WORK-DATE'] = common_date
|
||||||
|
|
||||||
|
# 锚点: LT='99'(CNS-NO-LEAVE), 第一个 MATCH 使 BEST-TYPE=99
|
||||||
|
anchor['R02LEAVE-TYPE'] = '99'
|
||||||
|
anchor['R02APPL-ID'] = '000000100'
|
||||||
|
anchor['R02START-TIME'] = '0800'
|
||||||
|
anchor['R02END-TIME'] = '1700'
|
||||||
|
|
||||||
|
# 14 scenario + 1 OTHER 按顺序覆盖各 WHEN 分支
|
||||||
|
# 路径: BEST 从 99→04→03→02→01 逐级覆盖, 末尾 LT='99' 进 WHEN OTHER
|
||||||
|
scenarios = [
|
||||||
|
('04', '000000080', '0800', '1700'), # LE04 ELSE(384-387)
|
||||||
|
('04', '000000060', '0800', '1700'), # LE04 AID<BEST(379-381)
|
||||||
|
('03', '000000050', '0800', '1700'), # LE03 ELSE(365-368)
|
||||||
|
('03', '000000040', '0800', '1700'), # LE03 AID<BEST(360-362)
|
||||||
|
('04', '000000045', '0800', '1700'), # LE04 CONT(BEST=03)(375-376)
|
||||||
|
('02', '000000030', '0800', '1700'), # LE02 ELSE(348-351)
|
||||||
|
('02', '000000020', '0800', '1700'), # LE02 AID<BEST(343-345)
|
||||||
|
('03', '000000025', '0800', '1700'), # LE03 CONT(BEST=02)(356-357)
|
||||||
|
('04', '000000030', '0800', '1700'), # LE04 CONT(BEST=02)(373-374)
|
||||||
|
('01', '000000010', '0800', '1700'), # LE01 NOT=01(329-332)
|
||||||
|
('01', '000000005', '0800', '1700'), # LE01 AID<BEST(334-336)
|
||||||
|
('02', '000000015', '0800', '1700'), # LE02 CONT(BEST=01)(339-340)
|
||||||
|
('03', '000000020', '0800', '1700'), # LE03 CONT(BEST=01)(354-355)
|
||||||
|
('04', '000000025', '0800', '1700'), # LE04 CONT(BEST=01)(371-372)
|
||||||
|
('99', '000000999', '0800', '1700'), # WHEN OTHER CONTINUE(390)
|
||||||
|
]
|
||||||
|
|
||||||
|
for j, idx in enumerate(normal_indices[1:]):
|
||||||
|
rec = records[idx]
|
||||||
|
lt, aid, st, et = scenarios[j]
|
||||||
|
rec['R02LEAVE-TYPE'] = lt
|
||||||
|
rec['R02APPL-ID'] = aid
|
||||||
|
rec['R02START-TIME'] = st
|
||||||
|
rec['R02END-TIME'] = et
|
||||||
|
|
||||||
|
logger.info(f" 2030PRIOSOR 全分支覆盖: {needed} 条记录注入完成 (共通キー={common_emp}/{common_date})")
|
||||||
|
|
||||||
|
|
||||||
def _inject_c01_coverage_records(records, fields, base_assignments):
|
def _inject_c01_coverage_records(records, fields, base_assignments):
|
||||||
"""Inject records for uncovered C01 decision points (DP#9-#12 T).
|
"""Inject records for uncovered C01 decision points (DP#9-#12 T).
|
||||||
|
|
||||||
@@ -477,8 +1498,8 @@ def main():
|
|||||||
)
|
)
|
||||||
vr = orch.run_all(generate_coverage=False)
|
vr = orch.run_all(generate_coverage=False)
|
||||||
|
|
||||||
# Copy output files to outdir
|
# Copy output files to outdir (skip if src == dst to avoid self-copy)
|
||||||
if orch.runtime_dir.exists():
|
if orch.runtime_dir.exists() and orch.runtime_dir.resolve() != prog_outdir.resolve():
|
||||||
for item in orch.runtime_dir.iterdir():
|
for item in orch.runtime_dir.iterdir():
|
||||||
if item.name == "gixsql.log":
|
if item.name == "gixsql.log":
|
||||||
continue
|
continue
|
||||||
@@ -661,7 +1682,7 @@ def main():
|
|||||||
if skip_path_infos:
|
if skip_path_infos:
|
||||||
logger.info(f" Skip 路径: {len(skip_path_infos)} 条(将单独生成数据集)")
|
logger.info(f" Skip 路径: {len(skip_path_infos)} 条(将单独生成数据集)")
|
||||||
|
|
||||||
open_dir = scan_open_statements(proc_div) if proc_div else {}
|
open_dir = scan_all_file_directions(proc_div) if proc_div else {}
|
||||||
|
|
||||||
if proc_div:
|
if proc_div:
|
||||||
logger.info(f"\n分支路径数:{len(branch_paths_with_assigns)}")
|
logger.info(f"\n分支路径数:{len(branch_paths_with_assigns)}")
|
||||||
@@ -681,7 +1702,7 @@ def main():
|
|||||||
path_infos = [([], {}, 'normal')]
|
path_infos = [([], {}, 'normal')]
|
||||||
roles = {f['name']: 'unused' for f in fields_dict}
|
roles = {f['name']: 'unused' for f in fields_dict}
|
||||||
|
|
||||||
records, _, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec)
|
records, kept_path_cons, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec)
|
||||||
|
|
||||||
def _is_eof_path(cons):
|
def _is_eof_path(cons):
|
||||||
last_eq1_true = -1
|
last_eq1_true = -1
|
||||||
@@ -711,6 +1732,20 @@ def main():
|
|||||||
_inject_empty_emp_rec(records, fields_dict)
|
_inject_empty_emp_rec(records, fields_dict)
|
||||||
# P5: inject records for uncovered C01 decision points (DP#9-#12 T)
|
# P5: inject records for uncovered C01 decision points (DP#9-#12 T)
|
||||||
_inject_c01_coverage_records(records, fields_dict, assignments)
|
_inject_c01_coverage_records(records, fields_dict, assignments)
|
||||||
|
# P6: coordinate cross-file keys for multi-file matching programs
|
||||||
|
_coordinate_multi_file_keys(records, kept_path_cons, fields_dict, assignments, file_sec, term_types=term_types, open_dir=open_dir)
|
||||||
|
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达)
|
||||||
|
_coordinate_range_matching(records, fields_dict, file_sec, open_dir, term_types)
|
||||||
|
# P7: inject leave-type coverage for 2030PRIOSOR EVALUATE branches
|
||||||
|
_inject_leave_type_scenarios(records, term_types)
|
||||||
|
# P8: opaque FD + READ INTO — 将 WS 子字段值序列化回 FD 记录字节
|
||||||
|
_serialize_read_into_records(records, assignments, fields_dict, file_sec)
|
||||||
|
# P9: MERGE 程序 — 为 USING 输入文件注入 S/B 记录
|
||||||
|
_inject_merge_input_records(records, fields_dict, file_sec, fd_fields, proc_div, term_types=term_types)
|
||||||
|
# P10: LINAGE 分页(WRITE AT END-OF-PAGE)— 补充分页体积记录
|
||||||
|
_inject_end_of_page_records(records, source, proc_div, file_sec, open_dir, term_types=term_types)
|
||||||
|
# P11: SORT 计数守卫(RELEASE + IF <cnt> <= <上限>)— 补充上限体积记录
|
||||||
|
_inject_sort_limit_records(records, proc_div, fields_dict, term_types=term_types)
|
||||||
|
|
||||||
if _HAVE_TOSQL:
|
if _HAVE_TOSQL:
|
||||||
sql_meta = collect_sql_meta(assignments, declared_columns)
|
sql_meta = collect_sql_meta(assignments, declared_columns)
|
||||||
@@ -736,6 +1771,11 @@ def main():
|
|||||||
term_types=term_types,
|
term_types=term_types,
|
||||||
data_fields=fields_dict, select_info=select_info)
|
data_fields=fields_dict, select_info=select_info)
|
||||||
|
|
||||||
|
# P12: 子程序输入供给(CALL 目标子程序的 INPUT FD 输入文件)
|
||||||
|
_provision_sub_input_files(
|
||||||
|
source, str(filepath.parent), prog_outdir / 'main' / 'input'
|
||||||
|
)
|
||||||
|
|
||||||
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
|
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
|
||||||
skip_records = None
|
skip_records = None
|
||||||
skip_term_types = None
|
skip_term_types = None
|
||||||
@@ -932,7 +1972,7 @@ def extract_structure(cobol_source: str, copybook_dirs: list = None) -> dict:
|
|||||||
branch_tree, assignments = build_branch_tree_fallback(proc_div, fields_dict)
|
branch_tree, assignments = build_branch_tree_fallback(proc_div, fields_dict)
|
||||||
|
|
||||||
file_sec = parse_file_section(preprocessed)
|
file_sec = parse_file_section(preprocessed)
|
||||||
open_dir = scan_open_statements(proc_div) if proc_div else {}
|
open_dir = scan_all_file_directions(proc_div) if proc_div else {}
|
||||||
|
|
||||||
from .models import BrIf, BrEval, BrSeq, BrPerform, BrSearch, Assign, CondAnd, CondOr
|
from .models import BrIf, BrEval, BrSeq, BrPerform, BrSearch, Assign, CondAnd, CondOr
|
||||||
|
|
||||||
@@ -1351,6 +2391,23 @@ def generate_data(cobol_source: str, structure: dict = None,
|
|||||||
if lhs in rec and rhs in rec and i < half:
|
if lhs in rec and rhs in rec and i < half:
|
||||||
rec[rhs] = rec[lhs]
|
rec[rhs] = rec[lhs]
|
||||||
|
|
||||||
|
# P6: 跨文件キー協調(DB/非DB 両パイプ共通)— 部分記錄のキーを
|
||||||
|
# 主表(R02/R03 等)と一致させ、照合 STAGE/DB SELECT 経路を到達可能にする。
|
||||||
|
# 出力 FD は open_dir で除外し、入力 FD 同士のみ協調する。
|
||||||
|
if records and file_sec:
|
||||||
|
try:
|
||||||
|
open_dir = scan_all_file_directions(proc_div or '')
|
||||||
|
_coordinate_multi_file_keys(
|
||||||
|
records, kept_paths, fields_dict, assignments,
|
||||||
|
file_sec, term_types=term_types, open_dir=open_dir,
|
||||||
|
)
|
||||||
|
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达,DB/非DB 共通)
|
||||||
|
_coordinate_range_matching(
|
||||||
|
records, fields_dict, file_sec, open_dir, term_types,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" cross-file key coordination skipped: {e}")
|
||||||
|
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+96
-12
@@ -98,10 +98,15 @@ def parse_single_condition(text, fields=None):
|
|||||||
if ' OF ' in text.upper():
|
if ' OF ' in text.upper():
|
||||||
text = text.split(' OF ')[0].strip()
|
text = text.split(' OF ')[0].strip()
|
||||||
|
|
||||||
# COBOL class condition: WS-KEY-DGT-N NUMERIC
|
# COBOL class condition: WS-KEY-DGT-N [IS] [NOT] NUMERIC/ALPHABETIC/...
|
||||||
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE):
|
# Return (field, 'IS', CLASS, want) — want=True → field must satisfy the class,
|
||||||
m = re.match(r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE)
|
# want=False → field must NOT satisfy the class (IS NOT / NOT).
|
||||||
return (m.group(1), '=', m.group(2).upper())
|
m = re.match(
|
||||||
|
r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(?:IS\s+)?'
|
||||||
|
r'(NOT\s+)?(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|ALPHABETIC-LOWER|POSITIVE|NEGATIVE|ZERO)\s*$',
|
||||||
|
text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return (m.group(1), 'IS', m.group(3).upper(), not bool(m.group(2)))
|
||||||
|
|
||||||
# Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y'
|
# Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y'
|
||||||
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE):
|
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE):
|
||||||
@@ -150,7 +155,7 @@ def parse_single_condition(text, fields=None):
|
|||||||
if text.upper().startswith('FUNCTION '):
|
if text.upper().startswith('FUNCTION '):
|
||||||
# After not_map normalization, NOT = has been converted to <>
|
# After not_map normalization, NOT = has been converted to <>
|
||||||
func_match = re.match(
|
func_match = re.match(
|
||||||
r'^FUNCTION\s+(\w+)\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$',
|
r'^FUNCTION\s+(\w+)\s*\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$',
|
||||||
normalized, re.IGNORECASE
|
normalized, re.IGNORECASE
|
||||||
)
|
)
|
||||||
if func_match:
|
if func_match:
|
||||||
@@ -244,6 +249,11 @@ def parse_compound_condition(text, fields=None):
|
|||||||
# Leaf condition
|
# Leaf condition
|
||||||
parsed = parse_single_condition(text, fields)
|
parsed = parse_single_condition(text, fields)
|
||||||
if parsed:
|
if parsed:
|
||||||
|
if len(parsed) == 4:
|
||||||
|
# class condition (field, 'IS', CLASS, want): represent negation via CondNot
|
||||||
|
field, op, cls, want = parsed
|
||||||
|
leaf = CondLeaf(field, op, cls)
|
||||||
|
return leaf if want else CondNot(leaf)
|
||||||
return CondLeaf(*parsed)
|
return CondLeaf(*parsed)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -332,12 +342,69 @@ def mcdc_sets(tree, fields=None):
|
|||||||
|
|
||||||
# ── 值计算 ──
|
# ── 值计算 ──
|
||||||
|
|
||||||
|
def evaluate_class_value(value, class_name):
|
||||||
|
"""COBOL class-condition evaluation on a raw field value.
|
||||||
|
|
||||||
|
Returns True if `value` belongs to `class_name`.
|
||||||
|
class_name ∈ {NUMERIC, ALPHABETIC, ALPHABETIC-UPPER, ALPHABETIC-LOWER,
|
||||||
|
POSITIVE, NEGATIVE, ZERO}. Empty / all-space values never
|
||||||
|
satisfy the ALPHABETIC* / NUMERIC classes (COBOL class-condition semantics).
|
||||||
|
"""
|
||||||
|
s = str(value)
|
||||||
|
cls = str(class_name).upper()
|
||||||
|
if cls == 'ALPHABETIC-UPPER':
|
||||||
|
return bool(s) and all('A' <= ch <= 'Z' for ch in s)
|
||||||
|
if cls == 'ALPHABETIC-LOWER':
|
||||||
|
return bool(s) and all('a' <= ch <= 'z' for ch in s)
|
||||||
|
if cls == 'ALPHABETIC':
|
||||||
|
return bool(s) and all(ch.isalpha() for ch in s)
|
||||||
|
if cls == 'NUMERIC':
|
||||||
|
return bool(s) and all('0' <= ch <= '9' for ch in s)
|
||||||
|
if cls in ('POSITIVE', 'NEGATIVE', 'ZERO'):
|
||||||
|
try:
|
||||||
|
n = float(str(s).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if cls == 'POSITIVE':
|
||||||
|
return n > 0
|
||||||
|
if cls == 'NEGATIVE':
|
||||||
|
return n < 0
|
||||||
|
return n == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str:
|
def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str:
|
||||||
ftype = field_info.get('type', 'unknown')
|
ftype = field_info.get('type', 'unknown')
|
||||||
digits = field_info.get('digits', 0)
|
digits = field_info.get('digits', 0)
|
||||||
decimal = field_info.get('decimal', 0)
|
decimal = field_info.get('decimal', 0)
|
||||||
total = digits + decimal
|
total = digits + decimal
|
||||||
|
|
||||||
|
# COBOL class-condition constraint: generate a value that is / is not in the class
|
||||||
|
if operator == 'IS':
|
||||||
|
cls = str(value).upper()
|
||||||
|
length = field_info.get('length', 1)
|
||||||
|
if cls in ('ALPHABETIC', 'ALPHABETIC-UPPER'):
|
||||||
|
if want_true:
|
||||||
|
return 'A' * length
|
||||||
|
return ('A' * (length - 1) + '0') if length > 1 else '0'
|
||||||
|
if cls == 'ALPHABETIC-LOWER':
|
||||||
|
if want_true:
|
||||||
|
return 'a' * length
|
||||||
|
return ('a' * (length - 1) + '0') if length > 1 else '0'
|
||||||
|
if cls == 'NUMERIC':
|
||||||
|
if want_true:
|
||||||
|
return '0' * length
|
||||||
|
return ('0' * max(length - 1, 1) + 'A') if length > 1 else 'A'
|
||||||
|
if cls == 'ZERO':
|
||||||
|
return '0' * max(total, 1)
|
||||||
|
if cls == 'POSITIVE':
|
||||||
|
return str(1).zfill(total) if total else '1'
|
||||||
|
if cls == 'NEGATIVE':
|
||||||
|
if field_info.get('signed'):
|
||||||
|
return '-' + '1'.zfill(max(total - 1, 1))
|
||||||
|
return '0' * max(total, 1)
|
||||||
|
return '0'.zfill(max(total, 1))
|
||||||
|
|
||||||
if ftype == 'numeric':
|
if ftype == 'numeric':
|
||||||
try:
|
try:
|
||||||
val_str = str(value)
|
val_str = str(value)
|
||||||
@@ -376,13 +443,31 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) ->
|
|||||||
|
|
||||||
elif ftype in ('alphanumeric', 'alphabetic'):
|
elif ftype in ('alphanumeric', 'alphabetic'):
|
||||||
length = field_info.get('length', 1)
|
length = field_info.get('length', 1)
|
||||||
base_chr = value[0].upper() if isinstance(value, str) and value else 'A'
|
# 图式常量解析:SPACES/SPACE→空格、ZERO(S)/ZEROES→'0'、LOW-VALUES→\x00、
|
||||||
|
# HIGH-VALUES→\xff、QUOTE(S)→"'"。否则按值首字符作基础字符。
|
||||||
|
_uv = str(value).strip().upper() if isinstance(value, str) else ''
|
||||||
|
_FIG = {
|
||||||
|
'SPACE': ' ', 'SPACES': ' ',
|
||||||
|
'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0',
|
||||||
|
'LOW-VALUE': '\x00', 'LOW-VALUES': '\x00',
|
||||||
|
'HIGH-VALUE': '\xff', 'HIGH-VALUES': '\xff',
|
||||||
|
'QUOTE': "'", 'QUOTES': "'",
|
||||||
|
}
|
||||||
|
base_chr = _FIG.get(_uv) or (value[0].upper() if isinstance(value, str) and value else 'A')
|
||||||
|
# 精确值目标:字面值(或图式常量)截断/右对齐到字段长。
|
||||||
|
# 例如 (= '0001', want=True) / (<> '0001', want=False) 应得到 '0001',
|
||||||
|
# 而非按首字符填充的 '0000'(旧行为错误)。
|
||||||
|
if _uv in _FIG:
|
||||||
|
_exact = (_FIG[_uv] * length)[:length]
|
||||||
|
else:
|
||||||
|
_exact = str(value)[:length].ljust(length)
|
||||||
|
_other = chr(65 + (ord(base_chr) - 64) % 26)
|
||||||
|
_diff = _other.ljust(length, _other)
|
||||||
if want_true:
|
if want_true:
|
||||||
if operator in ('=', '=='):
|
if operator in ('=', '=='):
|
||||||
return base_chr.ljust(length, base_chr)
|
return _exact
|
||||||
elif operator in ('<>', '!='):
|
elif operator in ('<>', '!='):
|
||||||
other = chr(65 + (ord(base_chr) - 64) % 26)
|
return _diff
|
||||||
return other.ljust(length, other)
|
|
||||||
elif operator == '>':
|
elif operator == '>':
|
||||||
sv = str(value)[:length].ljust(length)
|
sv = str(value)[:length].ljust(length)
|
||||||
chars = list(sv)
|
chars = list(sv)
|
||||||
@@ -403,10 +488,9 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) ->
|
|||||||
return ''.join(chars)
|
return ''.join(chars)
|
||||||
else:
|
else:
|
||||||
if operator in ('=', '=='):
|
if operator in ('=', '=='):
|
||||||
other = chr(65 + (ord(base_chr) - 64) % 26)
|
return _diff
|
||||||
return other.ljust(length, other)
|
|
||||||
elif operator in ('<>', '!='):
|
elif operator in ('<>', '!='):
|
||||||
return base_chr.ljust(length, base_chr)
|
return _exact
|
||||||
elif operator in ('>', '<'):
|
elif operator in ('>', '<'):
|
||||||
return str(value)[:length].ljust(length)
|
return str(value)[:length].ljust(length)
|
||||||
|
|
||||||
|
|||||||
+147
-11
@@ -161,9 +161,17 @@ class _BrParser:
|
|||||||
return self.lines[self.pos].strip()
|
return self.lines[self.pos].strip()
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def peek_next(self):
|
||||||
|
if self.pos + 1 < len(self.lines):
|
||||||
|
return self.lines[self.pos + 1].strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
return self.peek().rstrip('.').strip()
|
return self.peek().rstrip('.').strip()
|
||||||
|
|
||||||
|
def clean_next(self):
|
||||||
|
return self.peek_next().rstrip('.').strip()
|
||||||
|
|
||||||
def advance(self):
|
def advance(self):
|
||||||
self.pos += 1
|
self.pos += 1
|
||||||
|
|
||||||
@@ -217,6 +225,38 @@ class _BrParser:
|
|||||||
if perf_node:
|
if perf_node:
|
||||||
seq.add(perf_node)
|
seq.add(perf_node)
|
||||||
continue
|
continue
|
||||||
|
m_sm = re.match(r'^(MERGE|SORT)\s+(\w[\w-]*)\s*$', line)
|
||||||
|
if m_sm:
|
||||||
|
sort_file = m_sm.group(2).strip()
|
||||||
|
self.advance()
|
||||||
|
# 收集 MERGE/SORT 语句的续行(直到以 . 结尾)
|
||||||
|
stmt_parts = [line]
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
raw = self.peek()
|
||||||
|
stmt_parts.append(self.clean())
|
||||||
|
self.advance()
|
||||||
|
# clean() 会剥离末尾句点,须用原始行判断语句终止;
|
||||||
|
# 否则该循环永不退出,吞掉 SORT/MERGE 后的全部语句。
|
||||||
|
if raw.rstrip().endswith('.'):
|
||||||
|
break
|
||||||
|
stmt_text = ' '.join(stmt_parts)
|
||||||
|
# 提取 INPUT/OUTPUT PROCEDURE 段名并内联(如同 PERFORM 段)
|
||||||
|
for pm in re.finditer(
|
||||||
|
r'\b(?:INPUT|OUTPUT)\s+PROCEDURE\s+(\w[\w-]*)',
|
||||||
|
stmt_text, re.IGNORECASE
|
||||||
|
):
|
||||||
|
sec = pm.group(1).strip().upper()
|
||||||
|
if sec in self.paragraphs:
|
||||||
|
start, end = self.paragraphs[sec]
|
||||||
|
para_lines = self.raw_lines[start:end + 1]
|
||||||
|
sub = _BrParser(
|
||||||
|
[l for l in para_lines if l.strip()],
|
||||||
|
self.paragraphs, self.raw_lines, self.assignments, self.fields
|
||||||
|
)
|
||||||
|
sub_seq = sub.parse_seq()
|
||||||
|
for child in sub_seq.children:
|
||||||
|
seq.add(child)
|
||||||
|
continue
|
||||||
m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE)
|
m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE)
|
||||||
if m_search:
|
if m_search:
|
||||||
seq.add(self._parse_search(m_search))
|
seq.add(self._parse_search(m_search))
|
||||||
@@ -277,6 +317,23 @@ class _BrParser:
|
|||||||
break
|
break
|
||||||
self.advance()
|
self.advance()
|
||||||
continue
|
continue
|
||||||
|
m = re.match(r'^READ\s+(\w[\w-]*)\s*$', line, re.IGNORECASE)
|
||||||
|
if m and self.peek_next().startswith('INTO'):
|
||||||
|
tgt = self.clean_next().replace('INTO', '', 1).strip().upper()
|
||||||
|
if re.match(r'^\w[\w-]*$', tgt):
|
||||||
|
info = {'type': 'read_into', 'file': m.group(1).strip().upper(), 'source_vars': []}
|
||||||
|
self.assignments.setdefault(tgt, []).append(info)
|
||||||
|
seq.add(Assign(tgt, info))
|
||||||
|
self.advance()
|
||||||
|
self.advance()
|
||||||
|
# 跳过 READ 语句剩余行(AT END / NOT AT END / END-READ)
|
||||||
|
while self.pos < len(self.lines):
|
||||||
|
cl = self.clean()
|
||||||
|
if cl in ('END-READ', 'END-READ.'):
|
||||||
|
self.advance()
|
||||||
|
break
|
||||||
|
self.advance()
|
||||||
|
continue
|
||||||
m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE)
|
m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE)
|
||||||
if m_set_false:
|
if m_set_false:
|
||||||
seq.add(self._parse_set_false(m_set_false.group(1)))
|
seq.add(self._parse_set_false(m_set_false.group(1)))
|
||||||
@@ -796,6 +853,16 @@ class _BrParser:
|
|||||||
def _parse_perform(self):
|
def _parse_perform(self):
|
||||||
line = self.clean()
|
line = self.clean()
|
||||||
|
|
||||||
|
# PERFORM WITH TEST AFTER/BEFORE UNTIL ...(可换行)
|
||||||
|
m = re.match(r'^PERFORM\s+(?:WITH\s+TEST\s+(?:AFTER|BEFORE)\s+)?UNTIL\s+(.+?)\s*$', line)
|
||||||
|
if m:
|
||||||
|
node = BrPerform('until', condition=m.group(1).strip())
|
||||||
|
self.advance()
|
||||||
|
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
|
||||||
|
if self.clean() == 'END-PERFORM':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
|
||||||
m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line)
|
m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line)
|
||||||
if m:
|
if m:
|
||||||
node = BrPerform('until', condition=m.group(1).strip())
|
node = BrPerform('until', condition=m.group(1).strip())
|
||||||
@@ -965,6 +1032,28 @@ class _BrParser:
|
|||||||
self._inline_perform(node, target)
|
self._inline_perform(node, target)
|
||||||
return node
|
return node
|
||||||
|
|
||||||
|
# PERFORM WITH TEST AFTER/BEFORE(UNTIL 在下一行)
|
||||||
|
m = re.match(r'^PERFORM\s+WITH\s+TEST\s+(?:AFTER|BEFORE)\s*$', line)
|
||||||
|
if m:
|
||||||
|
save_pos = self.pos
|
||||||
|
condition = None
|
||||||
|
self.advance()
|
||||||
|
if self.pos < len(self.lines):
|
||||||
|
nxt = self.clean()
|
||||||
|
um = re.match(r'^UNTIL\s+(.+)$', nxt)
|
||||||
|
if um:
|
||||||
|
condition = um.group(1).strip()
|
||||||
|
self.advance()
|
||||||
|
if condition:
|
||||||
|
node = BrPerform('until', condition=condition)
|
||||||
|
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
|
||||||
|
if self.clean() == 'END-PERFORM':
|
||||||
|
self.advance()
|
||||||
|
return node
|
||||||
|
self.pos = save_pos
|
||||||
|
self.advance()
|
||||||
|
return None
|
||||||
|
|
||||||
self.advance()
|
self.advance()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1189,24 +1278,24 @@ class _BrParser:
|
|||||||
|
|
||||||
_RE_SELECT_INTO = re.compile(
|
_RE_SELECT_INTO = re.compile(
|
||||||
r'SELECT\s+(.*?)\s+INTO\s+(:\w[\w-]*(?:\s*,\s*:\w[\w-]*(?::\w[\w-]*)?)*)'
|
r'SELECT\s+(.*?)\s+INTO\s+(:\w[\w-]*(?:\s*,\s*:\w[\w-]*(?::\w[\w-]*)?)*)'
|
||||||
r'\s+FROM\s+(\w[\w-]*)',
|
r'\s+FROM\s+([\w-]+(?:\.[\w-]+)?)',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
_RE_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE)
|
_RE_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE)
|
||||||
|
|
||||||
_RE_SQL_INSERT = re.compile(
|
_RE_SQL_INSERT = re.compile(
|
||||||
r'INSERT\s+INTO\s+(\w[\w-]*)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)',
|
r'INSERT\s+INTO\s+([\w-]+(?:\.[\w-]+)?)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
_RE_SQL_DELETE = re.compile(
|
_RE_SQL_DELETE = re.compile(
|
||||||
r'DELETE\s+FROM\s+(\w[\w-]*)(?:\s+WHERE\s+(.+))?',
|
r'DELETE\s+FROM\s+([\w-]+(?:\.[\w-]+)?)(?:\s+WHERE\s+(.+))?',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
_RE_SQL_UPDATE = re.compile(
|
_RE_SQL_UPDATE = re.compile(
|
||||||
r'UPDATE\s+(\w[\w-]*)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$',
|
r'UPDATE\s+([\w-]+(?:\.[\w-]+)?)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1229,7 +1318,7 @@ class _BrParser:
|
|||||||
result = re.sub(r'\s+', ' ', result)
|
result = re.sub(r'\s+', ' ', result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _parse_sql(self, sql_text: str):
|
def _parse_sql(self, sql_text: str, pos: int = None):
|
||||||
"""Parse SQL text from EXEC SQL block. Returns Assign node or None."""
|
"""Parse SQL text from EXEC SQL block. Returns Assign node or None."""
|
||||||
# 1) SELECT ... INTO ... FROM
|
# 1) SELECT ... INTO ... FROM
|
||||||
m = self._RE_SELECT_INTO.search(sql_text)
|
m = self._RE_SELECT_INTO.search(sql_text)
|
||||||
@@ -1259,6 +1348,7 @@ class _BrParser:
|
|||||||
'into_vars': into_vars,
|
'into_vars': into_vars,
|
||||||
'where': where_clause,
|
'where': where_clause,
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
|
|
||||||
for var in into_vars:
|
for var in into_vars:
|
||||||
@@ -1288,6 +1378,7 @@ class _BrParser:
|
|||||||
'raw_values': values_str,
|
'raw_values': values_str,
|
||||||
'host_vars': host_vars,
|
'host_vars': host_vars,
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
synthetic = f'__SQL_INSERT_{table}'
|
synthetic = f'__SQL_INSERT_{table}'
|
||||||
self.assignments.setdefault(synthetic, []).append(info)
|
self.assignments.setdefault(synthetic, []).append(info)
|
||||||
@@ -1307,6 +1398,7 @@ class _BrParser:
|
|||||||
'where': where_clause,
|
'where': where_clause,
|
||||||
'host_vars': [h.upper() for h in host_vars],
|
'host_vars': [h.upper() for h in host_vars],
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
synthetic = f'__SQL_DELETE_{table}'
|
synthetic = f'__SQL_DELETE_{table}'
|
||||||
self.assignments.setdefault(synthetic, []).append(info)
|
self.assignments.setdefault(synthetic, []).append(info)
|
||||||
@@ -1314,7 +1406,7 @@ class _BrParser:
|
|||||||
|
|
||||||
# 4a) DECLARE CURSOR ... FOR SELECT ... FROM ...
|
# 4a) DECLARE CURSOR ... FOR SELECT ... FROM ...
|
||||||
m = re.search(
|
m = re.search(
|
||||||
r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+(\w[\w-]*)\s*(.*)',
|
r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+([\w-]+(?:\.[\w-]+)?)\s*(.*)',
|
||||||
sql_text, re.IGNORECASE
|
sql_text, re.IGNORECASE
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
@@ -1336,6 +1428,7 @@ class _BrParser:
|
|||||||
'into_vars': [],
|
'into_vars': [],
|
||||||
'where': where_clause,
|
'where': where_clause,
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
synthetic = f'__SQL_CURSOR_{from_table}'
|
synthetic = f'__SQL_CURSOR_{from_table}'
|
||||||
self.assignments.setdefault(synthetic, []).append(info)
|
self.assignments.setdefault(synthetic, []).append(info)
|
||||||
@@ -1365,6 +1458,7 @@ class _BrParser:
|
|||||||
'cursor_name': cursor_name,
|
'cursor_name': cursor_name,
|
||||||
'into_vars': into_vars,
|
'into_vars': into_vars,
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
for var in into_vars:
|
for var in into_vars:
|
||||||
self.assignments.setdefault(var, []).append(info)
|
self.assignments.setdefault(var, []).append(info)
|
||||||
@@ -1396,6 +1490,7 @@ class _BrParser:
|
|||||||
'where': where_clause,
|
'where': where_clause,
|
||||||
'host_vars': host_vars,
|
'host_vars': host_vars,
|
||||||
'sql_text': sql_text,
|
'sql_text': sql_text,
|
||||||
|
'pos': pos,
|
||||||
}
|
}
|
||||||
synthetic = f'__SQL_UPDATE_{table}'
|
synthetic = f'__SQL_UPDATE_{table}'
|
||||||
self.assignments.setdefault(synthetic, []).append(info)
|
self.assignments.setdefault(synthetic, []).append(info)
|
||||||
@@ -1477,13 +1572,54 @@ def trace_to_root(field_name, assignments, fields, path_assign=None):
|
|||||||
var = next_var
|
var = next_var
|
||||||
if next_var not in assignments:
|
if next_var not in assignments:
|
||||||
break
|
break
|
||||||
elif len(sv) >= 2 and asgn.get('op') == '+':
|
elif len(sv) >= 2 and asgn.get('op') in ('+', '-'):
|
||||||
# 多源加法:取第一个源变量继续追溯
|
# 多源加减:取第一个源变量继续追溯;
|
||||||
|
# 若其余源为常量字段(有 VALUE),折叠为单源 compute 供 invert 使用
|
||||||
|
folded = _fold_constants(asgn, fields)
|
||||||
|
if folded is not None:
|
||||||
|
chain[-1] = (var, folded)
|
||||||
var = sv[0]
|
var = sv[0]
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
return var, chain
|
return var, chain
|
||||||
|
|
||||||
|
def _fold_constants(asgn, fields):
|
||||||
|
"""将 2 源 compute 折叠为单源 compute + const,供链式反演使用。
|
||||||
|
|
||||||
|
例如: T = A - CST(CST 为 WORKING-STORAGE 常量字段)
|
||||||
|
→ 折叠为: T = A op const,其中 op 反转为 +(invert 时把 const 加回)。
|
||||||
|
返回新 asgn dict;若无法解析常量则返回 None(保持原 asgn)。
|
||||||
|
"""
|
||||||
|
if not asgn.get('source_vars') or len(asgn['source_vars']) != 2:
|
||||||
|
return None
|
||||||
|
sv = asgn['source_vars']
|
||||||
|
second = sv[1]
|
||||||
|
|
||||||
|
def _resolve(name):
|
||||||
|
for f in fields:
|
||||||
|
fname = f['name'] if isinstance(f, dict) else getattr(f, 'name', '')
|
||||||
|
if fname == name:
|
||||||
|
val = f.get('value', None) if isinstance(f, dict) else getattr(f, 'value', None)
|
||||||
|
if val is None:
|
||||||
|
val = (f.get('values') or [None])[0] if isinstance(f, dict) else \
|
||||||
|
getattr(f, 'values', None)
|
||||||
|
if isinstance(val, list):
|
||||||
|
val = val[0] if val else None
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return float(str(val).strip("'\""))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
c = _resolve(second)
|
||||||
|
if c is None:
|
||||||
|
return None
|
||||||
|
new = dict(asgn)
|
||||||
|
new['source_vars'] = [sv[0]]
|
||||||
|
new['const'] = c
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
def invert_through_chain(root_var, chain, operator, value):
|
def invert_through_chain(root_var, chain, operator, value):
|
||||||
op = operator
|
op = operator
|
||||||
@@ -1964,10 +2100,10 @@ def classify_field_roles(tree, assignments, fields, source=None, proc_text=None)
|
|||||||
# Phase 0: FD/OPEN 方向解析
|
# Phase 0: FD/OPEN 方向解析
|
||||||
fd_roles = {}
|
fd_roles = {}
|
||||||
if source and proc_text:
|
if source and proc_text:
|
||||||
from .read import parse_file_control, parse_file_section, scan_open_statements
|
from .read import parse_file_control, parse_file_section, scan_all_file_directions
|
||||||
file_ctl = parse_file_control(source)
|
file_ctl = parse_file_control(source)
|
||||||
file_sec = parse_file_section(source)
|
file_sec = parse_file_section(source)
|
||||||
open_dir = scan_open_statements(proc_text)
|
open_dir = scan_all_file_directions(proc_text)
|
||||||
for iname, direction in open_dir.items():
|
for iname, direction in open_dir.items():
|
||||||
if iname in file_sec:
|
if iname in file_sec:
|
||||||
for rec_name in file_sec[iname]:
|
for rec_name in file_sec[iname]:
|
||||||
@@ -2164,6 +2300,6 @@ def extract_sql_assignments(source: str) -> dict:
|
|||||||
|
|
||||||
for m in _RE_EXEC_SQL.finditer(source):
|
for m in _RE_EXEC_SQL.finditer(source):
|
||||||
sql_text = re.sub(r'\s+', ' ', m.group(1).strip())
|
sql_text = re.sub(r'\s+', ' ', m.group(1).strip())
|
||||||
parser._parse_sql(sql_text)
|
parser._parse_sql(sql_text, pos=m.start())
|
||||||
|
|
||||||
return dict(parser.assignments)
|
return dict(parser.assignments)
|
||||||
|
|||||||
+75
-21
@@ -229,17 +229,25 @@ def _mark_if(dp, cons):
|
|||||||
|
|
||||||
simple = getattr(dp, 'parsed', None)
|
simple = getattr(dp, 'parsed', None)
|
||||||
if simple:
|
if simple:
|
||||||
field, op, val = simple
|
if len(simple) == 4:
|
||||||
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
# class condition (field, 'IS', CLASS, base_want):
|
||||||
inv_simple = (field, inv_op, val)
|
# path want == base_want → T branch; == not base_want → F branch
|
||||||
for c in cons:
|
field, op, val, base_want = simple
|
||||||
if _match_constraint(c, simple):
|
for c in cons:
|
||||||
if c[3]:
|
if _match_constraint(c, (field, op, val)):
|
||||||
dp.active_branches.add('T')
|
dp.active_branches.add('T' if c[3] == base_want else 'F')
|
||||||
else:
|
else:
|
||||||
|
field, op, val = simple
|
||||||
|
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
||||||
|
inv_simple = (field, inv_op, val)
|
||||||
|
for c in cons:
|
||||||
|
if _match_constraint(c, simple):
|
||||||
|
if c[3]:
|
||||||
|
dp.active_branches.add('T')
|
||||||
|
else:
|
||||||
|
dp.active_branches.add('F')
|
||||||
|
elif _match_constraint(c, inv_simple):
|
||||||
dp.active_branches.add('F')
|
dp.active_branches.add('F')
|
||||||
elif _match_constraint(c, inv_simple):
|
|
||||||
dp.active_branches.add('F')
|
|
||||||
elif dp.cond_tree and dp.cond_leaves:
|
elif dp.cond_tree and dp.cond_leaves:
|
||||||
assignment = {}
|
assignment = {}
|
||||||
for leaf in dp.cond_leaves:
|
for leaf in dp.cond_leaves:
|
||||||
@@ -411,17 +419,24 @@ def _mark_perform(dp, cons):
|
|||||||
|
|
||||||
simple = getattr(dp, 'parsed', None)
|
simple = getattr(dp, 'parsed', None)
|
||||||
if simple:
|
if simple:
|
||||||
field, op, val = simple
|
if len(simple) == 4:
|
||||||
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
# class condition (field, 'IS', CLASS, base_want): path want == base_want → Skip
|
||||||
inv_simple = (field, inv_op, val)
|
field, op, val, base_want = simple
|
||||||
for c in cons:
|
for c in cons:
|
||||||
if _match_constraint(c, simple):
|
if _match_constraint(c, (field, op, val)):
|
||||||
if c[3]:
|
dp.active_branches.add('Skip' if c[3] == base_want else 'Enter')
|
||||||
dp.active_branches.add('Skip')
|
else:
|
||||||
else:
|
field, op, val = simple
|
||||||
|
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
||||||
|
inv_simple = (field, inv_op, val)
|
||||||
|
for c in cons:
|
||||||
|
if _match_constraint(c, simple):
|
||||||
|
if c[3]:
|
||||||
|
dp.active_branches.add('Skip')
|
||||||
|
else:
|
||||||
|
dp.active_branches.add('Enter')
|
||||||
|
elif _match_constraint(c, inv_simple):
|
||||||
dp.active_branches.add('Enter')
|
dp.active_branches.add('Enter')
|
||||||
elif _match_constraint(c, inv_simple):
|
|
||||||
dp.active_branches.add('Enter')
|
|
||||||
elif dp.cond_tree and dp.cond_leaves:
|
elif dp.cond_tree and dp.cond_leaves:
|
||||||
assignment = {}
|
assignment = {}
|
||||||
for leaf in dp.cond_leaves:
|
for leaf in dp.cond_leaves:
|
||||||
@@ -482,7 +497,42 @@ def locate_decision_lines(decision_points, raw_source):
|
|||||||
if re.search(short_pat, lines[i]):
|
if re.search(short_pat, lines[i]):
|
||||||
dp.source_line = i + 1
|
dp.source_line = i + 1
|
||||||
used_indices[dp.label] = i
|
used_indices[dp.label] = i
|
||||||
|
found = True
|
||||||
break
|
break
|
||||||
|
# Multi-line fallback 2: 条件被换行拆分(如 IF MERGE-REC-TYPE / = CONST)
|
||||||
|
# 用条件首个字段定位 IF 起始行(gcov 分支标记需要源行)
|
||||||
|
if not found and dp.kind == 'IF':
|
||||||
|
first_field_pat = _build_first_field_if_pattern(dp)
|
||||||
|
if first_field_pat:
|
||||||
|
for i in range(start, len(lines)):
|
||||||
|
if re.search(first_field_pat, lines[i]):
|
||||||
|
dp.source_line = i + 1
|
||||||
|
used_indices[dp.label] = i
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _build_first_field_if_pattern(dp):
|
||||||
|
"""Build a pattern matching 'IF <首字段>' — 用于条件被换行拆分的 IF。
|
||||||
|
|
||||||
|
例:条件 'MERGE-REC-TYPE = CNS-PAY-TYPE-SALARY' 在源码中写作
|
||||||
|
IF MERGE-REC-TYPE
|
||||||
|
= CNS-PAY-TYPE-SALARY
|
||||||
|
单行模式无法匹配;此模式仅匹配 'IF MERGE-REC-TYPE' 定位起始行。
|
||||||
|
"""
|
||||||
|
if dp.kind != 'IF':
|
||||||
|
return None
|
||||||
|
label = dp.label or ''
|
||||||
|
cond = label[2:].strip() if label.upper().startswith('IF ') else label
|
||||||
|
cond = cond.strip()
|
||||||
|
if not cond:
|
||||||
|
return None
|
||||||
|
# 去除前导 NOT / 括号,取首字段名
|
||||||
|
m = re.match(r"^(?:NOT\s+)?\(?([A-Z][A-Z0-9_-]*)", cond, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
first = m.group(1)
|
||||||
|
esc = re.escape(first)
|
||||||
|
return r'\bIF\b\s+' + esc + r'\b'
|
||||||
|
|
||||||
|
|
||||||
def _normalize(text):
|
def _normalize(text):
|
||||||
@@ -494,7 +544,11 @@ def _normalize(text):
|
|||||||
def _build_search_patterns(dp):
|
def _build_search_patterns(dp):
|
||||||
texts = []
|
texts = []
|
||||||
if dp.kind == 'IF':
|
if dp.kind == 'IF':
|
||||||
texts.append((r'\bIF\b', dp.label))
|
# dp.label is already like "IF SQLCODE = -803" — avoid emitting
|
||||||
|
# "\bIF\b\s+IF\s+..." which never matches a real source line.
|
||||||
|
label = dp.label or ''
|
||||||
|
cond = label[2:].strip() if label.upper().startswith('IF ') else label
|
||||||
|
texts.append((r'\bIF\b', cond))
|
||||||
elif dp.kind == 'EVALUATE':
|
elif dp.kind == 'EVALUATE':
|
||||||
texts.append((r'\bEVALUATE\b', dp.label))
|
texts.append((r'\bEVALUATE\b', dp.label))
|
||||||
elif dp.kind == 'PERFORM':
|
elif dp.kind == 'PERFORM':
|
||||||
|
|||||||
@@ -31,16 +31,16 @@ def _dedup(
|
|||||||
|
|
||||||
def _hash(rec, keys):
|
def _hash(rec, keys):
|
||||||
if keys:
|
if keys:
|
||||||
return tuple(rec.get(k, "") for k in keys)
|
return tuple(str(rec.get(k, "")) for k in keys)
|
||||||
return tuple(sorted(rec.items()))
|
return tuple((k, str(v) if isinstance(v, (list, dict, set)) else v) for k, v in sorted(rec.items()))
|
||||||
|
|
||||||
for rec in additional_records:
|
for rec in main_records:
|
||||||
h = _hash(rec, key_fields)
|
h = _hash(rec, key_fields)
|
||||||
if h not in seen:
|
if h not in seen:
|
||||||
seen.add(h)
|
seen.add(h)
|
||||||
result.append(rec)
|
result.append(rec)
|
||||||
|
|
||||||
for rec in main_records:
|
for rec in additional_records:
|
||||||
h = _hash(rec, key_fields)
|
h = _hash(rec, key_fields)
|
||||||
if h not in seen:
|
if h not in seen:
|
||||||
seen.add(h)
|
seen.add(h)
|
||||||
|
|||||||
+175
-36
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo
|
from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo
|
||||||
from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value
|
from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value, evaluate_class_value
|
||||||
from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename
|
from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -60,7 +61,7 @@ def _has_t_branch(cons):
|
|||||||
for c in cons:
|
for c in cons:
|
||||||
if len(c) >= 4 and c[0] == "__DP" and c[2] == "T":
|
if len(c) >= 4 and c[0] == "__DP" and c[2] == "T":
|
||||||
return True
|
return True
|
||||||
if c[3]:
|
if len(c) >= 4 and c[3]:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -68,16 +69,16 @@ def _has_f_branch(cons):
|
|||||||
for c in cons:
|
for c in cons:
|
||||||
if len(c) >= 4 and c[0] == "__DP" and c[2] == "F":
|
if len(c) >= 4 and c[0] == "__DP" and c[2] == "F":
|
||||||
return True
|
return True
|
||||||
if not c[3]:
|
if len(c) >= 4 and not c[3]:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cap_paths(paths):
|
def _cap_paths(paths):
|
||||||
if len(paths) > _MAX_PATHS:
|
if len(paths) > _MAX_PATHS:
|
||||||
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p)]
|
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p[0])]
|
||||||
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p)]
|
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p[0])]
|
||||||
t_paths = [(i, p) for i, p in std if _has_t_branch(p)]
|
t_paths = [(i, p) for i, p in std if _has_t_branch(p[0])]
|
||||||
f_paths = [(i, p) for i, p in std if _has_f_branch(p)]
|
f_paths = [(i, p) for i, p in std if _has_f_branch(p[0])]
|
||||||
quota = _MAX_PATHS - len(special)
|
quota = _MAX_PATHS - len(special)
|
||||||
if quota <= 0:
|
if quota <= 0:
|
||||||
return [p for _, p in special[:_MAX_PATHS]]
|
return [p for _, p in special[:_MAX_PATHS]]
|
||||||
@@ -210,7 +211,11 @@ def enum_paths(node, fields):
|
|||||||
continue
|
continue
|
||||||
for cp_cons, cp_assign in child_paths:
|
for cp_cons, cp_assign in child_paths:
|
||||||
merged_cons = p_cons + list(cp_cons)
|
merged_cons = p_cons + list(cp_cons)
|
||||||
sig = frozenset(_hashable_cons(merged_cons))
|
# 签名需保留约束的多重性:同一 (字段,运算符,值,期望) 在顺序
|
||||||
|
# IF 中重复出现(如连续 C01CHKRRC 校验)时,frozenset 会把
|
||||||
|
# 多重出现的约束折叠为单元素,导致"前面全部通过、最后一个校验
|
||||||
|
# 失败"等中间路径被去重丢失。改用按 repr 排序的元组签名。
|
||||||
|
sig = tuple(sorted(_hashable_cons(merged_cons), key=repr))
|
||||||
if sig not in covered_sigs:
|
if sig not in covered_sigs:
|
||||||
covered_sigs.add(sig)
|
covered_sigs.add(sig)
|
||||||
merged = {}
|
merged = {}
|
||||||
@@ -223,12 +228,24 @@ def enum_paths(node, fields):
|
|||||||
if not any(_is_sentinel(c) for c in pc):
|
if not any(_is_sentinel(c) for c in pc):
|
||||||
new_active.append((pc, dict(pa)))
|
new_active.append((pc, dict(pa)))
|
||||||
break
|
break
|
||||||
paths = new_active
|
paths = _cap_paths(new_active)
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
elif isinstance(node, BrIf):
|
elif isinstance(node, BrIf):
|
||||||
parsed = parse_single_condition(node.condition, fields)
|
parsed = parse_single_condition(node.condition, fields)
|
||||||
if parsed and is_field(parsed[0], fields):
|
if parsed and is_field(parsed[0], fields):
|
||||||
|
if len(parsed) == 4:
|
||||||
|
# class condition (field, 'IS', CLASS, base_want):
|
||||||
|
# T branch requires leaf truth = base_want, F branch = not base_want
|
||||||
|
field, op, val, base_want = parsed
|
||||||
|
paths = []
|
||||||
|
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
|
||||||
|
for sp_cons, sp_assign in (true_sub or [([], {})]):
|
||||||
|
paths.append(([(field, op, val, base_want)] + sp_cons, sp_assign))
|
||||||
|
false_sub = _cap_paths(enum_paths(node.false_seq, fields))
|
||||||
|
for fp_cons, fp_assign in (false_sub or [([], {})]):
|
||||||
|
paths.append(([(field, op, val, not base_want)] + fp_cons, fp_assign))
|
||||||
|
return paths
|
||||||
field, op, val = parsed
|
field, op, val = parsed
|
||||||
paths = []
|
paths = []
|
||||||
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
|
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
|
||||||
@@ -275,7 +292,7 @@ def enum_paths(node, fields):
|
|||||||
paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign))
|
paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign))
|
||||||
return paths
|
return paths
|
||||||
# Fallback: parsed condition but non-field (e.g. arithmetic expr)
|
# Fallback: parsed condition but non-field (e.g. arithmetic expr)
|
||||||
if parsed:
|
if parsed and len(parsed) == 3:
|
||||||
field, op, val = parsed
|
field, op, val = parsed
|
||||||
paths = []
|
paths = []
|
||||||
true_sub = enum_paths(node.true_seq, fields)
|
true_sub = enum_paths(node.true_seq, fields)
|
||||||
@@ -430,7 +447,7 @@ def enum_paths(node, fields):
|
|||||||
elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'):
|
elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'):
|
||||||
# 尝试单条件(现有逻辑)
|
# 尝试单条件(现有逻辑)
|
||||||
parsed = parse_single_condition(node.condition, fields)
|
parsed = parse_single_condition(node.condition, fields)
|
||||||
if parsed and is_field(parsed[0], fields):
|
if parsed and is_field(parsed[0], fields) and len(parsed) == 3:
|
||||||
field, op, val = parsed
|
field, op, val = parsed
|
||||||
paths = []
|
paths = []
|
||||||
# Skip 路径放在首位,确保不被 _cap_paths 截断丢失
|
# Skip 路径放在首位,确保不被 _cap_paths 截断丢失
|
||||||
@@ -890,6 +907,8 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
|
|||||||
val = rec.get(field_name)
|
val = rec.get(field_name)
|
||||||
if val is None:
|
if val is None:
|
||||||
return False
|
return False
|
||||||
|
if operator == 'IS':
|
||||||
|
return evaluate_class_value(val, value) == want_true
|
||||||
if operator == 'not_in':
|
if operator == 'not_in':
|
||||||
cases = value if isinstance(value, list) else []
|
cases = value if isinstance(value, list) else []
|
||||||
return str(val) not in cases
|
return str(val) not in cases
|
||||||
@@ -909,22 +928,32 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
|
|||||||
return ok == want_true
|
return ok == want_true
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
s_val = str(val).strip().upper()
|
s_val = str(val)
|
||||||
s_target = str(value).strip().upper()
|
s_target = str(value)
|
||||||
eq = s_val == s_target
|
# 图式常量:SPACES/SPACE 按空白比较(不剥离),否则按去除两端空白比较
|
||||||
|
_sv = s_val.strip().upper()
|
||||||
|
_tv = s_target.strip().upper()
|
||||||
|
if _tv in ('SPACE', 'SPACES'):
|
||||||
|
eq = (s_val.strip() == '')
|
||||||
|
elif _tv == 'LOW-VALUE':
|
||||||
|
eq = (s_val.strip('\x00') == '')
|
||||||
|
elif _tv == 'HIGH-VALUE':
|
||||||
|
eq = (s_val.strip('\xff') == '')
|
||||||
|
else:
|
||||||
|
eq = (_sv == _tv)
|
||||||
if operator == '=':
|
if operator == '=':
|
||||||
return eq == want_true
|
return eq == want_true
|
||||||
elif operator == '<>':
|
elif operator == '<>':
|
||||||
return (not eq) == want_true
|
return (not eq) == want_true
|
||||||
elif operator in ('>', '<', '>=', '<='):
|
elif operator in ('>', '<', '>=', '<='):
|
||||||
if operator == '>':
|
if operator == '>':
|
||||||
ok = s_val > s_target
|
ok = _sv > _tv
|
||||||
elif operator == '<':
|
elif operator == '<':
|
||||||
ok = s_val < s_target
|
ok = _sv < _tv
|
||||||
elif operator == '>=':
|
elif operator == '>=':
|
||||||
ok = s_val >= s_target
|
ok = _sv >= _tv
|
||||||
elif operator == '<=':
|
elif operator == '<=':
|
||||||
ok = s_val <= s_target
|
ok = _sv <= _tv
|
||||||
return ok == want_true
|
return ok == want_true
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1226,6 +1255,19 @@ def apply_constraint(rec, field_name, operator, value, want_true, fields, assign
|
|||||||
|
|
||||||
# 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性)
|
# 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性)
|
||||||
# 但零值时强制使用边界值(非 0/非 min)
|
# 但零值时强制使用边界值(非 0/非 min)
|
||||||
|
# 特例:字母数字字段 "字段 <> 空白" 期望为真时,即使当前值已含非空白字符,
|
||||||
|
# 也要强制整字段填满非空白。这是因为源条件可能使用引用修改(如
|
||||||
|
# WRK-C-INSURED-NO(11:)),仅"有非空白字符"不足以满足截取子串的判定。
|
||||||
|
_uv2 = str(value).strip().upper() if isinstance(value, str) else ''
|
||||||
|
if (want_true and operator in ('<>', '!=') and _uv2 in ('SPACE', 'SPACES')):
|
||||||
|
_fd2 = next((f for f in fields if f['name'] == field_name), None)
|
||||||
|
if _fd2 and _fd2.get('pic_info', {}).get('type') in ('alphanumeric', 'alphabetic'):
|
||||||
|
_pi2 = _fd2['pic_info']
|
||||||
|
_len2 = _pi2.get('length', 1)
|
||||||
|
_cur2 = str(rec.get(field_name, ''))
|
||||||
|
if _cur2.strip() and len(_cur2.rstrip()) < _len2:
|
||||||
|
rec[field_name] = ('U' * _len2)
|
||||||
|
return
|
||||||
if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
|
if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
|
||||||
cur = str(rec.get(field_name, '')).strip('0')
|
cur = str(rec.get(field_name, '')).strip('0')
|
||||||
if (cur == '' or cur == '.') and (
|
if (cur == '' or cur == '.') and (
|
||||||
@@ -1578,27 +1620,30 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
|
|||||||
pass
|
pass
|
||||||
if skip_impossible:
|
if skip_impossible:
|
||||||
continue
|
continue
|
||||||
# Pass B.0: CALL 返回码一致性 — 将要求返回码非零的约束转为入参无效化
|
# Pass B.0: CALL 返回码一致性 — 按调用级联顺序定位"首个失败"的校验入参。
|
||||||
|
# 仅将导致该次校验失败的字段置为无效,其余级联字段修复为有效值,
|
||||||
|
# 使路径真正执行到目标校验分支(修复先前"全部入参无效化"导致提前分支底的问题)。
|
||||||
_b0_invalidated = set()
|
_b0_invalidated = set()
|
||||||
new_cons = []
|
new_cons = []
|
||||||
_b0_rrc_counter = 0
|
_b0_rrc_counter = 0
|
||||||
|
_b0_cascade_plan = _plan_cascade_failures(path_cons, base_assignments)
|
||||||
|
# 对每个外部校验家系应用级联修复:首个"要求失败"的入参置无效,
|
||||||
|
# 其余入参修复为有效值;全通过(fail_pos=None)则全部修复为有效值。
|
||||||
|
# 覆盖"前面校验通过、最后一项校验失败"与"全部通过"两类路径。
|
||||||
|
for _fam, _plan in (_b0_cascade_plan or {}).items():
|
||||||
|
_feeds = _plan.get('feeds') or []
|
||||||
|
_fail_pos = _plan.get('fail_pos')
|
||||||
|
for _i, _src in enumerate(_feeds):
|
||||||
|
if _src not in rec:
|
||||||
|
continue
|
||||||
|
if _fail_pos is not None and _i == _fail_pos:
|
||||||
|
_set_invalid_value(rec, _src, data_fields, base_assignments)
|
||||||
|
_b0_invalidated.add(_src)
|
||||||
|
else:
|
||||||
|
_repair_valid_checked_field(rec, _src, data_fields)
|
||||||
for c in path_cons:
|
for c in path_cons:
|
||||||
if len(c) == 4 and c[1] == '<>' and c[3] and c[0].endswith('RRC'):
|
if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
|
||||||
rrc_field = c[0]
|
|
||||||
prefix = rrc_field[:-3]
|
|
||||||
_b0_rrc_counter += 1
|
_b0_rrc_counter += 1
|
||||||
for tgt, asgn_list in base_assignments.items():
|
|
||||||
if tgt.startswith(prefix) and tgt != rrc_field:
|
|
||||||
for asgn in asgn_list:
|
|
||||||
atyp = asgn.get('type', '').upper()
|
|
||||||
src = None
|
|
||||||
if atyp == 'MOVE':
|
|
||||||
src = asgn.get('src', asgn.get('source_vars', [None])[0] if asgn.get('source_vars') else None)
|
|
||||||
elif atyp == 'move' and asgn.get('source_vars'):
|
|
||||||
src = asgn['source_vars'][0]
|
|
||||||
if src and isinstance(src, str) and src in rec:
|
|
||||||
_set_invalid_value(rec, src, data_fields)
|
|
||||||
_b0_invalidated.add(src)
|
|
||||||
continue
|
continue
|
||||||
new_cons.append(c)
|
new_cons.append(c)
|
||||||
path_cons = new_cons
|
path_cons = new_cons
|
||||||
@@ -1744,6 +1789,10 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
|
|||||||
_status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1')
|
_status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1')
|
||||||
if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec:
|
if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec:
|
||||||
_is_c01chk_want_true = True
|
_is_c01chk_want_true = True
|
||||||
|
# 命令行馈入字段 → 运行时命令行参数(供运行器传递,覆盖 PARM 校验分支)
|
||||||
|
_cli_args = _collect_cli_args(rec, base_assignments, data_fields)
|
||||||
|
if _cli_args:
|
||||||
|
rec['__CLI_ARGS__'] = _cli_args
|
||||||
records.append(rec)
|
records.append(rec)
|
||||||
kept_path_cons.append(path_cons)
|
kept_path_cons.append(path_cons)
|
||||||
term_types.append(term_type)
|
term_types.append(term_type)
|
||||||
@@ -1861,15 +1910,105 @@ def _sync_unstring_targets_from_output(rec, base_assignments, data_fields):
|
|||||||
rec[src] = tgt_val
|
rec[src] = tgt_val
|
||||||
|
|
||||||
|
|
||||||
def _set_invalid_value(rec, field_name, data_fields):
|
def _set_invalid_value(rec, field_name, data_fields, assignments=None):
|
||||||
"""将字段设为无效值,用于触发 CALL 返回码非零。"""
|
"""将字段设为无效值,用于触发 CALL 返回码非零。"""
|
||||||
|
# 命令行馈入字段(ACCEPT ... FROM COMMAND-LINE):空白会被程序当作缺省值
|
||||||
|
# 处理(如 IF x = SPACES MOVE '202605'),因此必须用非空白无效值;
|
||||||
|
# 否则运行时取缺省值导致 PARM 校验分支(ABEND 路径)无法覆盖。
|
||||||
|
_is_cli = False
|
||||||
|
if assignments:
|
||||||
|
_al = assignments.get(field_name)
|
||||||
|
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
|
||||||
|
for _a in _al:
|
||||||
|
if _a.get('type') == 'accept':
|
||||||
|
_is_cli = True
|
||||||
|
break
|
||||||
for f in data_fields:
|
for f in data_fields:
|
||||||
if f['name'] == field_name:
|
if f['name'] == field_name:
|
||||||
pi = f.get('pic_info', {})
|
pi = f.get('pic_info', {})
|
||||||
ftype = pi.get('type', '')
|
ftype = pi.get('type', '')
|
||||||
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
|
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
|
||||||
if ftype in ('alphanumeric', 'alphabetic'):
|
if ftype in ('alphanumeric', 'alphabetic'):
|
||||||
rec[field_name] = ' ' * length
|
if _is_cli:
|
||||||
|
rec[field_name] = ('Z' * length) if length else 'ZZZZZZ'
|
||||||
|
else:
|
||||||
|
rec[field_name] = ' ' * length
|
||||||
else:
|
else:
|
||||||
rec[field_name] = '9' * length
|
rec[field_name] = '9' * length
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_cli_args(rec, base_assignments, data_fields):
|
||||||
|
"""收集命令行馈入字段(ACCEPT ... FROM COMMAND-LINE)的运行时参数值。
|
||||||
|
|
||||||
|
返回 {字段名: 值},供运行器作为程序命令行参数传递。通用、无程序硬编码。
|
||||||
|
"""
|
||||||
|
if not base_assignments:
|
||||||
|
return {}
|
||||||
|
cli = {}
|
||||||
|
for f in data_fields:
|
||||||
|
name = f['name']
|
||||||
|
if name in rec:
|
||||||
|
_al = base_assignments.get(name)
|
||||||
|
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
|
||||||
|
if any(_a.get('type') == 'accept' for _a in _al):
|
||||||
|
cli[name] = str(rec.get(name, ''))
|
||||||
|
return cli
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_cascade_failures(path_cons, base_assignments):
|
||||||
|
"""预计算每个外部校验家系(如 C01CHK)的"首个失败"校验位置与级联入参顺序。
|
||||||
|
|
||||||
|
返回:{ family: {'feeds': [入参字段...], 'fail_pos': int|None} }
|
||||||
|
- feeds:按调用顺序排列的校验入参字段(C01CHKDAT 的 MOVE 源)。
|
||||||
|
- fail_pos:本路径中首个要求返回码非零的校验下标(无则为 None —— 全通过)。
|
||||||
|
该映射与 target 无关、不含程序名硬编码,对所有使用外部校验子(SUB04 等)重命名的 family 通用。
|
||||||
|
"""
|
||||||
|
plan = {}
|
||||||
|
feeds_map = {}
|
||||||
|
for tgt, alist in (base_assignments or {}).items():
|
||||||
|
for a in alist:
|
||||||
|
atyp = str(a.get('type', '')).upper()
|
||||||
|
if atyp in ('MOVE', 'MOVE_LITERAL') and a.get('source_vars'):
|
||||||
|
for src in a.get('source_vars', []):
|
||||||
|
if isinstance(src, str):
|
||||||
|
feeds_map.setdefault(tgt, []).append(src)
|
||||||
|
# 返回码字段形如 family+'RRC';入参数据字段形如 family+'DAT'(<校验数据>)。
|
||||||
|
# 汇总同一 family 的 需要/通过 标志,取首个"要求失败"处为 fail_pos。
|
||||||
|
want_order = []
|
||||||
|
for c in path_cons:
|
||||||
|
if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
|
||||||
|
want_order.append((c[0][:-3], bool(c[3])))
|
||||||
|
fam_wants = {}
|
||||||
|
for fam, want in want_order:
|
||||||
|
fam_wants.setdefault(fam, []).append(want)
|
||||||
|
for fam, wants in fam_wants.items():
|
||||||
|
data_field = None
|
||||||
|
for tgt in feeds_map:
|
||||||
|
if tgt.startswith(fam) and 'DAT' in tgt.upper():
|
||||||
|
data_field = tgt
|
||||||
|
break
|
||||||
|
feeds = feeds_map.get(data_field, []) if data_field else []
|
||||||
|
fail_pos = None
|
||||||
|
for i, want in enumerate(wants):
|
||||||
|
if want:
|
||||||
|
fail_pos = i
|
||||||
|
break
|
||||||
|
plan[fam] = {'feeds': feeds, 'fail_pos': fail_pos}
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_valid_checked_field(rec, field_name, data_fields):
|
||||||
|
"""将校验入参修复为可通过外部校验器的有效值(日期/数字),供后续校验分支通过。"""
|
||||||
|
if field_name not in rec:
|
||||||
|
return
|
||||||
|
for f in data_fields:
|
||||||
|
if f['name'] == field_name:
|
||||||
|
pi = f.get('pic_info', {})
|
||||||
|
ftype = pi.get('type', '')
|
||||||
|
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
|
||||||
|
if ftype == 'numeric':
|
||||||
|
rec[field_name] = '1' * length if length else rec[field_name]
|
||||||
|
elif _is_date_field(field_name):
|
||||||
|
rec[field_name] = '20240115'
|
||||||
|
return
|
||||||
|
|||||||
+150
-27
@@ -35,6 +35,9 @@ def _invert_condition(parsed):
|
|||||||
"""Invert a parsed condition (True ↔ False)."""
|
"""Invert a parsed condition (True ↔ False)."""
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return None
|
return None
|
||||||
|
if len(parsed) == 4:
|
||||||
|
# class condition (field, 'IS', CLASS, want) → flip want
|
||||||
|
return (parsed[0], parsed[1], parsed[2], not parsed[3])
|
||||||
field, op, val = parsed
|
field, op, val = parsed
|
||||||
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
|
||||||
return (field, inv_op, val)
|
return (field, inv_op, val)
|
||||||
@@ -78,13 +81,43 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
|
|||||||
t_cons = list(path_cons)
|
t_cons = list(path_cons)
|
||||||
f_cons = list(path_cons)
|
f_cons = list(path_cons)
|
||||||
if parsed:
|
if parsed:
|
||||||
field, op, val = parsed
|
if len(parsed) == 4:
|
||||||
t_cons.append((field, op, val, True))
|
field, op, val, base_want = parsed
|
||||||
f_cons.append((field, op, val, False))
|
t_cons.append((field, op, val, base_want))
|
||||||
|
f_cons.append((field, op, val, not base_want))
|
||||||
|
else:
|
||||||
|
field, op, val = parsed
|
||||||
|
t_cons.append((field, op, val, True))
|
||||||
|
f_cons.append((field, op, val, False))
|
||||||
else:
|
else:
|
||||||
# Synthetic constraint for coverage matching
|
# Generate MC/DC leaf-level constraints for compound conditions
|
||||||
t_cons.append(("__DP", str(dp_id), "T", True))
|
cond_tree = parse_compound_condition(node.condition, fields)
|
||||||
f_cons.append(("__DP", str(dp_id), "F", True))
|
if cond_tree:
|
||||||
|
sets = mcdc_sets(cond_tree, fields)
|
||||||
|
if sets:
|
||||||
|
t_set = f_set = None
|
||||||
|
for c, decision in sets:
|
||||||
|
if decision and t_set is None:
|
||||||
|
t_set = c
|
||||||
|
elif not decision and f_set is None:
|
||||||
|
f_set = c
|
||||||
|
if t_set is not None and f_set is not None:
|
||||||
|
break
|
||||||
|
if t_set:
|
||||||
|
t_cons.extend(t_set)
|
||||||
|
else:
|
||||||
|
t_cons.append(("__DP", str(dp_id), "T", True))
|
||||||
|
if f_set:
|
||||||
|
f_cons.extend(f_set)
|
||||||
|
else:
|
||||||
|
f_cons.append(("__DP", str(dp_id), "F", True))
|
||||||
|
dp["false_idx"] = 1
|
||||||
|
else:
|
||||||
|
t_cons.append(("__DP", str(dp_id), "T", True))
|
||||||
|
f_cons.append(("__DP", str(dp_id), "F", True))
|
||||||
|
else:
|
||||||
|
t_cons.append(("__DP", str(dp_id), "T", True))
|
||||||
|
f_cons.append(("__DP", str(dp_id), "F", True))
|
||||||
result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1, _counter))
|
result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1, _counter))
|
||||||
result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1, _counter))
|
result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1, _counter))
|
||||||
|
|
||||||
@@ -128,8 +161,12 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
|
|||||||
}
|
}
|
||||||
result.append(dp)
|
result.append(dp)
|
||||||
if parsed:
|
if parsed:
|
||||||
field, op, val = parsed
|
if len(parsed) == 4:
|
||||||
body_cons = list(path_cons) + [(field, op, val, False)]
|
field, op, val, base_want = parsed
|
||||||
|
body_cons = list(path_cons) + [(field, op, val, not base_want)]
|
||||||
|
else:
|
||||||
|
field, op, val = parsed
|
||||||
|
body_cons = list(path_cons) + [(field, op, val, False)]
|
||||||
else:
|
else:
|
||||||
# Synthetic constraint for coverage matching
|
# Synthetic constraint for coverage matching
|
||||||
body_cons = list(path_cons) + [("__DP", str(dp_id), "ENTER", True)]
|
body_cons = list(path_cons) + [("__DP", str(dp_id), "ENTER", True)]
|
||||||
@@ -142,8 +179,10 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
|
|||||||
result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth, _counter))
|
result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth, _counter))
|
||||||
|
|
||||||
elif isinstance(node, BrSearch):
|
elif isinstance(node, BrSearch):
|
||||||
|
dp_id = _counter[0]
|
||||||
|
_counter[0] += 1
|
||||||
dp = {
|
dp = {
|
||||||
"node": node, "kind": "SEARCH",
|
"node": node, "kind": "SEARCH", "id": dp_id,
|
||||||
"access_constraints": list(path_cons),
|
"access_constraints": list(path_cons),
|
||||||
}
|
}
|
||||||
result.append(dp)
|
result.append(dp)
|
||||||
@@ -165,17 +204,37 @@ def _make_path_for_branch(dp, branch_idx, fields):
|
|||||||
dp_id = dp.get("id", 0)
|
dp_id = dp.get("id", 0)
|
||||||
want_true = (branch_idx == dp.get("true_idx", 0))
|
want_true = (branch_idx == dp.get("true_idx", 0))
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
# Use synthetic __DP constraint for coverage matching
|
# Generate MC/DC leaf-level constraints for compound conditions
|
||||||
label = "T" if want_true else "F"
|
cond_tree = parse_compound_condition(dp["node"].condition, fields)
|
||||||
constraints.append(("__DP", str(dp_id), label, True))
|
if cond_tree:
|
||||||
|
sets = mcdc_sets(cond_tree, fields)
|
||||||
|
if sets:
|
||||||
|
for c, decision in sets:
|
||||||
|
if decision == want_true:
|
||||||
|
constraints.extend(c)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
label = "T" if want_true else "F"
|
||||||
|
constraints.append(("__DP", str(dp_id), label, True))
|
||||||
|
else:
|
||||||
|
label = "T" if want_true else "F"
|
||||||
|
constraints.append(("__DP", str(dp_id), label, True))
|
||||||
|
else:
|
||||||
|
label = "T" if want_true else "F"
|
||||||
|
constraints.append(("__DP", str(dp_id), label, True))
|
||||||
node = dp["node"]
|
node = dp["node"]
|
||||||
body_seq = node.true_seq if branch_idx == 0 else node.false_seq
|
body_seq = node.true_seq if branch_idx == 0 else node.false_seq
|
||||||
else:
|
else:
|
||||||
field, op, val = parsed
|
if len(parsed) == 4:
|
||||||
if not want_true:
|
field, op, val, base_want = parsed
|
||||||
field2, op2, val2 = _invert_condition(parsed)
|
leaf_want = base_want if want_true else (not base_want)
|
||||||
field, op, val = field2, op2, val2
|
constraints.append((field, op, val, leaf_want))
|
||||||
constraints.append((field, op, val, True))
|
else:
|
||||||
|
field, op, val = parsed
|
||||||
|
if not want_true:
|
||||||
|
field2, op2, val2 = _invert_condition(parsed)
|
||||||
|
field, op, val = field2, op2, val2
|
||||||
|
constraints.append((field, op, val, True))
|
||||||
node = dp["node"]
|
node = dp["node"]
|
||||||
body_seq = node.true_seq if branch_idx == 0 else node.false_seq
|
body_seq = node.true_seq if branch_idx == 0 else node.false_seq
|
||||||
return (constraints, {})
|
return (constraints, {})
|
||||||
@@ -215,11 +274,38 @@ def _make_path_for_branch(dp, branch_idx, fields):
|
|||||||
label = "ENTER" if branch_idx == 0 else "SKIP"
|
label = "ENTER" if branch_idx == 0 else "SKIP"
|
||||||
constraints.append(("__DP", str(dp_id), label, True))
|
constraints.append(("__DP", str(dp_id), label, True))
|
||||||
return (constraints, {})
|
return (constraints, {})
|
||||||
field, op, val = parsed
|
if len(parsed) == 4:
|
||||||
if branch_idx == 0:
|
field, op, val, base_want = parsed
|
||||||
constraints.append((field, op, val, False))
|
leaf_want = (not base_want) if branch_idx == 0 else base_want
|
||||||
|
constraints.append((field, op, val, leaf_want))
|
||||||
else:
|
else:
|
||||||
constraints.append((field, op, val, True))
|
field, op, val = parsed
|
||||||
|
if branch_idx == 0:
|
||||||
|
constraints.append((field, op, val, False))
|
||||||
|
else:
|
||||||
|
constraints.append((field, op, val, True))
|
||||||
|
return (constraints, {})
|
||||||
|
|
||||||
|
if kind == "SEARCH":
|
||||||
|
node = dp["node"]
|
||||||
|
dp_id = dp.get("id", 0)
|
||||||
|
n_when = len(node.when_list)
|
||||||
|
if branch_idx < n_when:
|
||||||
|
cond_text, _ = node.when_list[branch_idx]
|
||||||
|
cond_tree = node.cond_trees[branch_idx] if branch_idx < len(node.cond_trees) else None
|
||||||
|
if cond_tree and isinstance(cond_tree, CondLeaf):
|
||||||
|
base = re.sub(r"\s*\(.*?\)\s*$", "", cond_tree.field)
|
||||||
|
elem_key = f"{base}({branch_idx + 1})"
|
||||||
|
subj = cond_tree.value
|
||||||
|
if is_field(subj, fields):
|
||||||
|
# Field-to-field: constrain subject against the table element
|
||||||
|
constraints.append((subj, cond_tree.op, elem_key, True))
|
||||||
|
else:
|
||||||
|
constraints.append((elem_key, cond_tree.op, str(subj).rstrip(), True))
|
||||||
|
else:
|
||||||
|
constraints.append(("__DP", str(dp_id), "W%d" % branch_idx, True))
|
||||||
|
return (constraints, {})
|
||||||
|
# AT END branch (branch_idx == n_when): leave access constraints only
|
||||||
return (constraints, {})
|
return (constraints, {})
|
||||||
|
|
||||||
return ([], {})
|
return ([], {})
|
||||||
@@ -245,12 +331,39 @@ def enum_paths(node, fields):
|
|||||||
kind = dp["kind"]
|
kind = dp["kind"]
|
||||||
|
|
||||||
if kind == "IF":
|
if kind == "IF":
|
||||||
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
|
parsed = dp.get("parsed")
|
||||||
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
|
if parsed is None:
|
||||||
if true_path:
|
# Compound condition: add ALL MC/DC paths (one per leaf)
|
||||||
paths.append(true_path)
|
cond_tree = parse_compound_condition(dp["node"].condition, fields)
|
||||||
if false_path:
|
if cond_tree:
|
||||||
paths.append(false_path)
|
sets = mcdc_sets(cond_tree, fields)
|
||||||
|
if sets:
|
||||||
|
seen = set()
|
||||||
|
for c, decision in sets:
|
||||||
|
key = frozenset(c)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
new_cons = list(dp.get("access_constraints", []))
|
||||||
|
new_cons.extend(c)
|
||||||
|
paths.append((new_cons, {}))
|
||||||
|
else:
|
||||||
|
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
|
||||||
|
if true_path: paths.append(true_path)
|
||||||
|
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
|
||||||
|
if false_path: paths.append(false_path)
|
||||||
|
else:
|
||||||
|
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
|
||||||
|
if true_path: paths.append(true_path)
|
||||||
|
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
|
||||||
|
if false_path: paths.append(false_path)
|
||||||
|
else:
|
||||||
|
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
|
||||||
|
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
|
||||||
|
if true_path:
|
||||||
|
paths.append(true_path)
|
||||||
|
if false_path:
|
||||||
|
paths.append(false_path)
|
||||||
|
|
||||||
elif kind == "EVALUATE":
|
elif kind == "EVALUATE":
|
||||||
node = dp["node"]
|
node = dp["node"]
|
||||||
@@ -275,6 +388,16 @@ def enum_paths(node, fields):
|
|||||||
if enter_path: paths.append(enter_path)
|
if enter_path: paths.append(enter_path)
|
||||||
if skip_path: paths.append(skip_path)
|
if skip_path: paths.append(skip_path)
|
||||||
|
|
||||||
|
elif kind == "SEARCH":
|
||||||
|
node = dp["node"]
|
||||||
|
n_when = len(node.when_list)
|
||||||
|
for i in range(n_when):
|
||||||
|
wp = _make_path_for_branch(dp, i, fields)
|
||||||
|
if wp: paths.append(wp)
|
||||||
|
if node.has_at_end:
|
||||||
|
at = _make_path_for_branch(dp, n_when, fields)
|
||||||
|
if at: paths.append(at)
|
||||||
|
|
||||||
if len(paths) >= MAX_PATH:
|
if len(paths) >= MAX_PATH:
|
||||||
paths = paths[:MAX_PATH]
|
paths = paths[:MAX_PATH]
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ def get_storage_length(field: dict) -> int:
|
|||||||
else:
|
else:
|
||||||
return 8
|
return 8
|
||||||
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
||||||
return (digits + 2) // 2
|
# 打包小数含小数位(如 9(3)V9(2)=5 位+符号 → 3 字节)。
|
||||||
|
# 只算整数位(digits)会少算 1 字节,导致与 GnuCOBOL FD 布局错位。
|
||||||
|
decimal = pi.get('decimal', 0)
|
||||||
|
return (digits + decimal + 2) // 2
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported USAGE: {usage}")
|
raise ValueError(f"Unsupported USAGE: {usage}")
|
||||||
|
|
||||||
@@ -74,10 +77,12 @@ def pack_value(value: str, field: dict) -> bytes:
|
|||||||
fmt = fmt_map[size]
|
fmt = fmt_map[size]
|
||||||
if not signed:
|
if not signed:
|
||||||
fmt = fmt.upper()
|
fmt = fmt.upper()
|
||||||
return struct.pack('<' + fmt, int_val)
|
# 本 GnuCOBOL(GC32-BDB-SP1、主机兼容)的 COMP 按大端存储。
|
||||||
|
return struct.pack('>' + fmt, int_val)
|
||||||
|
|
||||||
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
elif usage in ('COMP-3', 'PACKED-DECIMAL'):
|
||||||
abs_str = str(abs(int_val)).zfill(digits)
|
# 打包长度含小数位(digits + decimal)
|
||||||
|
abs_str = str(abs(int_val)).zfill(digits + pi.get('decimal', 0))
|
||||||
nibbles = [int(ch) for ch in abs_str]
|
nibbles = [int(ch) for ch in abs_str]
|
||||||
if not signed:
|
if not signed:
|
||||||
nibbles.append(0xF)
|
nibbles.append(0xF)
|
||||||
@@ -112,7 +117,8 @@ def unpack_value(data: bytes, field: dict) -> str:
|
|||||||
fmt = fmt_map[size]
|
fmt = fmt_map[size]
|
||||||
if not signed:
|
if not signed:
|
||||||
fmt = fmt.upper()
|
fmt = fmt.upper()
|
||||||
val = struct.unpack('<' + fmt, data)[0]
|
# 与 pack_value 一致:本 GnuCOBOL 的 COMP 为大端存储。
|
||||||
|
val = struct.unpack('>' + fmt, data)[0]
|
||||||
sign = '-' if val < 0 else ''
|
sign = '-' if val < 0 else ''
|
||||||
return f"{sign}{str(abs(val)).zfill(digits)}"
|
return f"{sign}{str(abs(val)).zfill(digits)}"
|
||||||
|
|
||||||
@@ -127,7 +133,8 @@ def unpack_value(data: bytes, field: dict) -> str:
|
|||||||
num_str = ''.join(chars).lstrip('0') or '0'
|
num_str = ''.join(chars).lstrip('0') or '0'
|
||||||
if signed and sign == 0xD:
|
if signed and sign == 0xD:
|
||||||
num_str = '-' + num_str
|
num_str = '-' + num_str
|
||||||
return num_str.zfill(digits)
|
# 补零长度含小数位
|
||||||
|
return num_str.zfill(digits + pi.get('decimal', 0))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported USAGE: {usage}")
|
raise ValueError(f"Unsupported USAGE: {usage}")
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ def _format_value(value: Any, field: dict) -> bytes:
|
|||||||
try:
|
try:
|
||||||
num = int(float(val)) if val else 0
|
num = int(float(val)) if val else 0
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
num = 0
|
# 非数字値を 0 に静かに丸めると '00000000' が合成され、
|
||||||
|
# 複数レコードで主キー衝突(DAILY_RECORDS INSERT エラー→早期 ABEND)を
|
||||||
|
# 引き起こす。SPACE を書くことでプログラムの空キー判定に委ねる。
|
||||||
|
return (' ' * length).encode("ascii")
|
||||||
num = abs(num)
|
num = abs(num)
|
||||||
max_val = 10 ** length - 1
|
max_val = 10 ** length - 1
|
||||||
if num > max_val:
|
if num > max_val:
|
||||||
@@ -295,6 +298,12 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix
|
|||||||
|
|
||||||
# Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path
|
# Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path
|
||||||
unique_ids = list(dict.fromkeys(emp_ids_ordered))
|
unique_ids = list(dict.fromkeys(emp_ids_ordered))
|
||||||
|
# Ensure a duplicated EMP-ID (from AGG path injection in orchestrator_db)
|
||||||
|
# appears in the last T card chunk. Without this, dict.fromkeys removes
|
||||||
|
# duplicates keeping only the first occurrence, which may not be in last 8.
|
||||||
|
dup_candidates = [e for e in unique_ids if emp_ids_ordered.count(e) > 1]
|
||||||
|
if dup_candidates and dup_candidates[0] not in unique_ids[-8:]:
|
||||||
|
unique_ids.append(dup_candidates[0])
|
||||||
if not unique_ids:
|
if not unique_ids:
|
||||||
unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"]
|
unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"]
|
||||||
# Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE
|
# Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE
|
||||||
@@ -313,8 +322,10 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix
|
|||||||
# Unknown card type to cover IF WRK-CARD-TYPE '*' ELSE branch (DP#8 F)
|
# Unknown card type to cover IF WRK-CARD-TYPE '*' ELSE branch (DP#8 F)
|
||||||
lines.append("X UNKNOWN")
|
lines.append("X UNKNOWN")
|
||||||
|
|
||||||
# End with RESET mode so 3000STPSOR runs the RESET path (DP#22-#23)
|
# End with the configured final mode (default RESET → 3000STPSOR 実行 RESET path).
|
||||||
lines.append("M MODE=RESET")
|
# final_mode='NORMAL' 时(如 drop_tables 场景),3000STPSOR 不执行 DELETE,
|
||||||
|
# 使 SELECT COUNT 等后续 SQL 错误分支可达。
|
||||||
|
lines.append(f"M MODE={run_cfg.get('final_mode', 'RESET')}")
|
||||||
|
|
||||||
# Write as fixed-length flat file
|
# Write as fixed-length flat file
|
||||||
outpath = outdir / (prefix + sysin_filename)
|
outpath = outdir / (prefix + sysin_filename)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ occurs_clause: "OCCURS" INT ("TO" INT)? ("TIME" "S"?)? ("DEPENDING" "ON" NAME)?
|
|||||||
key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)*
|
key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)*
|
||||||
indexed_clause: "INDEXED" "BY" NAME (","? NAME)*
|
indexed_clause: "INDEXED" "BY" NAME (","? NAME)*
|
||||||
usage_clause: "USAGE"? "IS"? USAGE_VAL
|
usage_clause: "USAGE"? "IS"? USAGE_VAL
|
||||||
USAGE_VAL: "COMP" | "COMP-3" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY"
|
USAGE_VAL: "COMP" | "COMP-3" | "COMP-4" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY" | "POINTER"
|
||||||
LEVEL: /0[1-9]|[0-4][0-9]|49|66|77|88|[0-9]+/
|
LEVEL: /0[1-9]|[0-4][0-9]|49|66|77|88|[0-9]+/
|
||||||
NAME: /[A-Z][A-Z0-9-]*/i
|
NAME: /[A-Z][A-Z0-9-]*/i
|
||||||
PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i
|
PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def output_json(records, outpath, roles=None, fd_fields=None, field_to_fd=None,
|
|||||||
entry = {
|
entry = {
|
||||||
'input': inp,
|
'input': inp,
|
||||||
'expected_output': out_exp,
|
'expected_output': out_exp,
|
||||||
'working_storage': {k: v for k, v in ws.items() if k != '_assigned_fields'},
|
'working_storage': {k: v for k, v in ws.items() if k not in ('_assigned_fields', '__CLI_ARGS__')},
|
||||||
'termination': term_types[i] if i < len(term_types) else 'normal',
|
'termination': term_types[i] if i < len(term_types) else 'normal',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +176,7 @@ def output_input_files(records, outdir, stem, roles, fd_fields, field_to_fd, ope
|
|||||||
bin_path = outdir / assign_name
|
bin_path = outdir / assign_name
|
||||||
name_to_field = {
|
name_to_field = {
|
||||||
f['name']: f for f in data_fields
|
f['name']: f for f in data_fields
|
||||||
if not f.get('is_88') and not f.get('is_filler')
|
if not f.get('is_88') and f.get('pic')
|
||||||
and f.get('pic')
|
|
||||||
}
|
}
|
||||||
field_dicts = []
|
field_dicts = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|||||||
@@ -26,12 +26,36 @@ def build_branch_tree_fallback(proc_text, fields=None):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# New parser generates O(N) paths (not O(2^N)), so no cap needed.
|
# New parser generates O(N) paths (not O(2^N)), so no cap needed.
|
||||||
# Just use it directly when it works.
|
# When new parser succeeds, also try the old parser with short timeout.
|
||||||
|
# If old parser succeeds and has more IF nodes, use it (new parser may miss
|
||||||
|
# nested ifs inside PERFORM UNTIL/SECTION blocks).
|
||||||
|
old_tree, old_assigns = None, {}
|
||||||
if new_tree is not None:
|
if new_tree is not None:
|
||||||
|
old_fallback, old_fb_assigns = None, {}
|
||||||
|
try:
|
||||||
|
import threading
|
||||||
|
r, e, d = [None], [None], [False]
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
r[0] = old_build(proc_text, fields)
|
||||||
|
except Exception as ex:
|
||||||
|
e[0] = ex
|
||||||
|
d[0] = True
|
||||||
|
t = threading.Thread(target=run, daemon=True)
|
||||||
|
t.start(); t.join(3.0)
|
||||||
|
if d[0] and not e[0] and r[0]:
|
||||||
|
old_fallback, old_fb_assigns = r[0]
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
if old_fallback is not None:
|
||||||
|
old_with_cond = _count_brif_with_cond(old_fallback)
|
||||||
|
new_with_cond = _count_brif_with_cond(new_tree)
|
||||||
|
if old_with_cond > new_with_cond:
|
||||||
|
logger.info(f" Old parser: {old_with_cond} BrIf with cond_tree vs new parser {new_with_cond}, using old parser")
|
||||||
|
return old_fallback, old_fb_assigns
|
||||||
return new_tree, new_assigns
|
return new_tree, new_assigns
|
||||||
|
|
||||||
# 2. Old parser with 3s timeout (fallback only)
|
# 2. Old parser with 3s timeout (fallback only)
|
||||||
old_tree, old_assigns = None, {}
|
|
||||||
try:
|
try:
|
||||||
import threading
|
import threading
|
||||||
r, e, d = [None], [None], [False]
|
r, e, d = [None], [None], [False]
|
||||||
@@ -174,6 +198,49 @@ def _count_br_nodes(node) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _count_brif(node):
|
||||||
|
"""Count BrIf nodes in a branch tree."""
|
||||||
|
from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch
|
||||||
|
count = 0
|
||||||
|
if isinstance(node, BrIf):
|
||||||
|
count += 1
|
||||||
|
if isinstance(node, BrSeq):
|
||||||
|
for c in node.children: count += _count_brif(c)
|
||||||
|
if isinstance(node, BrIf):
|
||||||
|
count += _count_brif(node.true_seq) + _count_brif(node.false_seq)
|
||||||
|
if isinstance(node, BrEval):
|
||||||
|
for _, s in node.when_list: count += _count_brif(s)
|
||||||
|
count += _count_brif(node.other_seq)
|
||||||
|
if isinstance(node, BrPerform):
|
||||||
|
count += _count_brif(node.body_seq)
|
||||||
|
if isinstance(node, BrSearch):
|
||||||
|
count += _count_brif(node.at_end_seq)
|
||||||
|
for _, s in node.when_list: count += _count_brif(s)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _count_brif_with_cond(node):
|
||||||
|
"""Count BrIf nodes that have cond_tree set."""
|
||||||
|
from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch
|
||||||
|
count = 0
|
||||||
|
if isinstance(node, BrIf):
|
||||||
|
if node.cond_tree is not None:
|
||||||
|
count += 1
|
||||||
|
if isinstance(node, BrSeq):
|
||||||
|
for c in node.children: count += _count_brif_with_cond(c)
|
||||||
|
if isinstance(node, BrIf):
|
||||||
|
count += _count_brif_with_cond(node.true_seq) + _count_brif_with_cond(node.false_seq)
|
||||||
|
if isinstance(node, BrEval):
|
||||||
|
for _, s in node.when_list: count += _count_brif_with_cond(s)
|
||||||
|
count += _count_brif_with_cond(node.other_seq)
|
||||||
|
if isinstance(node, BrPerform):
|
||||||
|
count += _count_brif_with_cond(node.body_seq)
|
||||||
|
if isinstance(node, BrSearch):
|
||||||
|
count += _count_brif_with_cond(node.at_end_seq)
|
||||||
|
for _, s in node.when_list: count += _count_brif_with_cond(s)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def _assigns_list_to_dict(assigns_list: list) -> dict:
|
def _assigns_list_to_dict(assigns_list: list) -> dict:
|
||||||
result = {}
|
result = {}
|
||||||
for a in assigns_list:
|
for a in assigns_list:
|
||||||
|
|||||||
+67
-6
@@ -207,13 +207,13 @@ _RE_SQL_INC = re.compile(
|
|||||||
_BUILTIN_SQLCA = """\
|
_BUILTIN_SQLCA = """\
|
||||||
01 SQLCA.
|
01 SQLCA.
|
||||||
05 SQLCAID PIC X(8).
|
05 SQLCAID PIC X(8).
|
||||||
05 SQLCABC PIC S9(9) COMP.
|
05 SQLCABC PIC S9(9) COMP-5.
|
||||||
05 SQLCODE PIC S9(9) COMP.
|
05 SQLCODE PIC S9(9) COMP-5.
|
||||||
05 SQLERRM.
|
05 SQLERRM.
|
||||||
10 SQLERRML PIC S9(4) COMP.
|
10 SQLERRML PIC S9(4) COMP-5.
|
||||||
10 SQLERRMC PIC X(70).
|
10 SQLERRMC PIC X(70).
|
||||||
05 SQLERRP PIC X(8).
|
05 SQLERRP PIC X(8).
|
||||||
05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP.
|
05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP-5.
|
||||||
05 SQLWARN.
|
05 SQLWARN.
|
||||||
10 SQLWARN0 PIC X.
|
10 SQLWARN0 PIC X.
|
||||||
10 SQLWARN1 PIC X.
|
10 SQLWARN1 PIC X.
|
||||||
@@ -523,6 +523,11 @@ def parse_pic(pic_str: str) -> PicInfo:
|
|||||||
elif expanded[0] == 'A':
|
elif expanded[0] == 'A':
|
||||||
info.type = 'alphabetic'
|
info.type = 'alphabetic'
|
||||||
info.length = len(expanded)
|
info.length = len(expanded)
|
||||||
|
elif expanded[0] == 'N':
|
||||||
|
# PIC N(n) = national/DBCS: 每个字符占 2 字节(GnuCOBOL 实际存储)。
|
||||||
|
# 按 n 字节计算会导致 FD 记录布局/偏移错位(如 N(040)=80B 却按 40B 写)。
|
||||||
|
info.type = 'national'
|
||||||
|
info.length = len(expanded) * 2
|
||||||
elif expanded[0] in ('Z', '*', '$', '+', '-'):
|
elif expanded[0] in ('Z', '*', '$', '+', '-'):
|
||||||
info.type = 'numeric-edited'
|
info.type = 'numeric-edited'
|
||||||
info.digits = expanded.count('9')
|
info.digits = expanded.count('9')
|
||||||
@@ -597,15 +602,17 @@ def parse_file_control(source: str) -> dict:
|
|||||||
result = {}
|
result = {}
|
||||||
for sel_m in re.finditer(
|
for sel_m in re.finditer(
|
||||||
r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+'
|
r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+'
|
||||||
r'(?:(["\'])(.*?)\2|(\w[\w-]*))'
|
r'(?:(["\'])(.*?)\2|EXTERNAL\s+(\w[\w-]*)|(\w[\w-]*))'
|
||||||
r'[^.]*\.',
|
r'[^.]*\.',
|
||||||
fc, re.IGNORECASE
|
fc, re.IGNORECASE
|
||||||
):
|
):
|
||||||
name = sel_m.group(1).upper()
|
name = sel_m.group(1).upper()
|
||||||
if sel_m.group(2):
|
if sel_m.group(2):
|
||||||
assign_to = sel_m.group(3).upper()
|
assign_to = sel_m.group(3).upper()
|
||||||
else:
|
elif sel_m.group(4):
|
||||||
assign_to = sel_m.group(4).upper()
|
assign_to = sel_m.group(4).upper()
|
||||||
|
else:
|
||||||
|
assign_to = sel_m.group(5).upper()
|
||||||
clause = sel_m.group(0)
|
clause = sel_m.group(0)
|
||||||
org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE)
|
org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE)
|
||||||
if org_m and org_m.group(1):
|
if org_m and org_m.group(1):
|
||||||
@@ -673,3 +680,57 @@ def scan_open_statements(source: str) -> dict:
|
|||||||
for fname in re.findall(r'\w[\w-]*', seg_m.group(2)):
|
for fname in re.findall(r'\w[\w-]*', seg_m.group(2)):
|
||||||
dirs[fname.upper()] = direction
|
dirs[fname.upper()] = direction
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def scan_sort_merge_directions(source: str) -> dict:
|
||||||
|
"""Parse MERGE / SORT statements, returns {file_name: 'INPUT'|'OUTPUT'}.
|
||||||
|
|
||||||
|
MERGE/SORT 的 USING 文件不会被 OPEN(由语句隐式打开),因此
|
||||||
|
scan_open_statements 无法识别。此函数扫描 PROCEDURE DIVISION 中
|
||||||
|
MERGE ... USING <f1> <f2> 与 SORT ... USING <f> GIVING <g> 子句,
|
||||||
|
将 USING 文件识别为 INPUT、GIVING 文件识别为 OUTPUT。
|
||||||
|
|
||||||
|
兼容性:对任何含 MERGE/SORT 的程序通用,非 MERGE/SORT 程序返回空 dict。
|
||||||
|
"""
|
||||||
|
dirs = {}
|
||||||
|
_KEYWORDS = {'USING', 'GIVING', 'PROCEDURE', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT'}
|
||||||
|
for m in re.finditer(
|
||||||
|
r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.',
|
||||||
|
source, re.IGNORECASE | re.DOTALL
|
||||||
|
):
|
||||||
|
stmt = m.group(0)
|
||||||
|
kind = m.group(1).upper()
|
||||||
|
files_raw = m.group(2)
|
||||||
|
files = re.findall(r'\b(\w[\w-]*)\b', files_raw)
|
||||||
|
for fn in files:
|
||||||
|
fn = fn.upper()
|
||||||
|
if fn not in _KEYWORDS:
|
||||||
|
dirs[fn] = 'INPUT'
|
||||||
|
if kind == 'SORT':
|
||||||
|
g = re.search(r'\bGIVING\s+(\w[\w-]*)', stmt, re.IGNORECASE)
|
||||||
|
if g:
|
||||||
|
dirs[g.group(1).upper()] = 'OUTPUT'
|
||||||
|
# 单行回退:无 PROCEDURE 的 MERGE/SORT(如 MERGE F ON KEY K USING A B.)
|
||||||
|
for m in re.finditer(
|
||||||
|
r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\.',
|
||||||
|
source, re.IGNORECASE
|
||||||
|
):
|
||||||
|
kind = m.group(1).upper()
|
||||||
|
files_raw = m.group(2)
|
||||||
|
files = re.findall(r'\b(\w[\w-]*)\b', files_raw)
|
||||||
|
for fn in files:
|
||||||
|
fn = fn.upper()
|
||||||
|
if fn not in _KEYWORDS:
|
||||||
|
dirs[fn] = 'INPUT'
|
||||||
|
if kind == 'SORT':
|
||||||
|
g = re.search(r'\bGIVING\s+(\w[\w-]*)', m.group(0), re.IGNORECASE)
|
||||||
|
if g:
|
||||||
|
dirs[g.group(1).upper()] = 'OUTPUT'
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def scan_all_file_directions(source: str) -> dict:
|
||||||
|
"""OPEN 方向 + MERGE/SORT USING/GIVING 方向的合并(输入方向全集)。"""
|
||||||
|
dirs = scan_open_statements(source)
|
||||||
|
dirs.update(scan_sort_merge_directions(source))
|
||||||
|
return dirs
|
||||||
|
|||||||
+36
-7
@@ -31,6 +31,7 @@ class GroupInfo:
|
|||||||
select_info: dict = field(default_factory=dict)
|
select_info: dict = field(default_factory=dict)
|
||||||
overlap_mask: list[bool] = field(default_factory=list)
|
overlap_mask: list[bool] = field(default_factory=list)
|
||||||
multi_write_fds: set = field(default_factory=set)
|
multi_write_fds: set = field(default_factory=set)
|
||||||
|
command_args: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -335,7 +336,7 @@ def run_group(group: GroupInfo, exe_path: str, temp_dir: str,
|
|||||||
try:
|
try:
|
||||||
os.chdir(str(work_dir))
|
os.chdir(str(work_dir))
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(exe)], capture_output=True, text=True,
|
[str(exe)] + list(group.command_args or []), capture_output=True, text=True,
|
||||||
encoding='utf-8', errors='replace', timeout=60,
|
encoding='utf-8', errors='replace', timeout=60,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -434,26 +435,36 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
|
|||||||
gcov_root = work_dir / "gcov"
|
gcov_root = work_dir / "gcov"
|
||||||
|
|
||||||
for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes:
|
for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes:
|
||||||
# ── 3a. 入力ファイル配置 ──
|
# ── 3a. 入力ファイル配置(主程序 + 被调子程序的输入文件全部复制)──
|
||||||
|
# 仅复制 assign_names 会漏掉子程序输入文件(测试驱动调用读文件自程序时
|
||||||
|
# 会 OPEN 失败 ABEND)。复制目录内全部非 JSON 文件(.json 为 V3 元数据)。
|
||||||
if src_in_dir.is_dir():
|
if src_in_dir.is_dir():
|
||||||
for assign in assign_names:
|
for src in sorted(src_in_dir.iterdir()):
|
||||||
src = src_in_dir / assign
|
if not src.is_file() or src.suffix.lower() == '.json':
|
||||||
if src.exists():
|
continue
|
||||||
shutil.copy2(str(src), str(work_dir / assign))
|
shutil.copy2(str(src), str(work_dir / src.name))
|
||||||
|
|
||||||
# ── 3b. 清理旧 gcda ──
|
# ── 3b. 清理旧 gcda ──
|
||||||
_clean_gcda(str(work_dir))
|
_clean_gcda(str(work_dir))
|
||||||
|
|
||||||
# ── 3c. 过滤 non-abend ──
|
# ── 3c. 过滤 non-abend ──
|
||||||
filtered_exp = []
|
filtered_exp = []
|
||||||
|
normal_recs = []
|
||||||
|
abend_pairs = [] # (rec, command_args)
|
||||||
for i, rec in enumerate(scene_expected):
|
for i, rec in enumerate(scene_expected):
|
||||||
term = scene_terms[i] if i < len(scene_terms) else 'normal'
|
term = scene_terms[i] if i < len(scene_terms) else 'normal'
|
||||||
if term != 'abend':
|
if term != 'abend':
|
||||||
filtered_exp.append(rec)
|
filtered_exp.append(rec)
|
||||||
|
for i, rec in enumerate(scene_recs):
|
||||||
|
term = scene_terms[i] if i < len(scene_terms) else 'normal'
|
||||||
|
if term == 'abend':
|
||||||
|
abend_pairs.append((rec, list((rec.get('__CLI_ARGS__') or {}).values())))
|
||||||
|
else:
|
||||||
|
normal_recs.append(rec)
|
||||||
|
|
||||||
group = GroupInfo(
|
group = GroupInfo(
|
||||||
name=f"{program_name}_{scene_id}",
|
name=f"{program_name}_{scene_id}",
|
||||||
records=scene_recs,
|
records=normal_recs,
|
||||||
expected_outputs=filtered_exp,
|
expected_outputs=filtered_exp,
|
||||||
expected_returncode=0,
|
expected_returncode=0,
|
||||||
fd_field_dicts=fd_field_dicts,
|
fd_field_dicts=fd_field_dicts,
|
||||||
@@ -469,6 +480,23 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
|
|||||||
status = '✓' if r.passed else '✗'
|
status = '✓' if r.passed else '✗'
|
||||||
logger.info(f" 组 '{group.name}': returncode={r.returncode}, {status}")
|
logger.info(f" 组 '{group.name}': returncode={r.returncode}, {status}")
|
||||||
|
|
||||||
|
# ── 3d-2. abend 记录单独执行(带命令行参数,触发 ABEND/异常返回)──
|
||||||
|
for ab_idx, (ab_rec, ab_args) in enumerate(abend_pairs):
|
||||||
|
ab_group = GroupInfo(
|
||||||
|
name=f"{program_name}_{scene_id}_abend_{ab_idx + 1}",
|
||||||
|
records=[ab_rec], expected_outputs=[],
|
||||||
|
expected_returncode=1,
|
||||||
|
fd_field_dicts=fd_field_dicts,
|
||||||
|
open_dir=open_dir,
|
||||||
|
select_info=select_info,
|
||||||
|
multi_write_fds=multi_write_fds,
|
||||||
|
command_args=ab_args,
|
||||||
|
)
|
||||||
|
r2 = run_group(ab_group, exe_path, str(work_dir), log_dir=log_dir)
|
||||||
|
results.append(r2)
|
||||||
|
status2 = '✓' if r2.passed else '✗'
|
||||||
|
logger.info(f" 组 '{ab_group.name}': returncode={r2.returncode}, {status2}")
|
||||||
|
|
||||||
# ── 3e. 出力保存 ──
|
# ── 3e. 出力保存 ──
|
||||||
dst_out_dir.mkdir(parents=True, exist_ok=True)
|
dst_out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for fd_name in fd_field_dicts:
|
for fd_name in fd_field_dicts:
|
||||||
@@ -579,6 +607,7 @@ def run_and_compare(program_name: str, outdir: str,
|
|||||||
expected_returncode=1,
|
expected_returncode=1,
|
||||||
fd_field_dicts=fd_field_dicts, open_dir=open_dir,
|
fd_field_dicts=fd_field_dicts, open_dir=open_dir,
|
||||||
select_info=select_info,
|
select_info=select_info,
|
||||||
|
command_args=list((rec.get('__CLI_ARGS__') or {}).values()),
|
||||||
)
|
)
|
||||||
r = run_group(group, exe_path, temp_dir, log_dir=log_dir)
|
r = run_group(group, exe_path, temp_dir, log_dir=log_dir)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
+642
-36
@@ -1,4 +1,4 @@
|
|||||||
"""SQL层:WHERE约束解析 + DB输入行生成"""
|
"""SQL层:WHERE约束解析 + DB输入行生成"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
@@ -28,8 +28,97 @@ def _restore_strings(text: str, replacements: list) -> str:
|
|||||||
|
|
||||||
# ── Bracket-aware AND splitting ──
|
# ── Bracket-aware AND splitting ──
|
||||||
|
|
||||||
def _split_on_AND(text: str) -> list[str]:
|
_RE_BETWEEN_SPAN = re.compile(r'\bBETWEEN\b', re.IGNORECASE)
|
||||||
"""Split WHERE clause on AND, respecting parentheses."""
|
|
||||||
|
|
||||||
|
def _scan_between_spans(text: str) -> (str, list):
|
||||||
|
"""Replace `X BETWEEN lo AND hi` spans with `__BTW{i}__` placeholders.
|
||||||
|
|
||||||
|
The inner AND that belongs to a BETWEEN clause must not be treated as a
|
||||||
|
top-level AND separator. Returns (protected_text, spans) where each span is:
|
||||||
|
{subject, lo, hi, neg} — each operand is the raw token string.
|
||||||
|
Subject may be a host variable (`:NAME`), a column, or a literal; lo/hi
|
||||||
|
likewise (host var, column, or literal). The full span text (subject
|
||||||
|
through hi) is replaced by a single placeholder token.
|
||||||
|
"""
|
||||||
|
spans = []
|
||||||
|
out = []
|
||||||
|
pos = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
m = _RE_BETWEEN_SPAN.search(text, i)
|
||||||
|
if not m:
|
||||||
|
break
|
||||||
|
# subject: token (optionally ':'-prefixed) immediately before an
|
||||||
|
# optional NOT that precedes BETWEEN
|
||||||
|
j = m.start() - 1
|
||||||
|
while j >= 0 and text[j].isspace():
|
||||||
|
j -= 1
|
||||||
|
before = text[max(0, j - 5):m.start()]
|
||||||
|
mnot = re.search(r'NOT\s*$', before, re.IGNORECASE)
|
||||||
|
if mnot:
|
||||||
|
j = m.start() - len(mnot.group(0)) - 1
|
||||||
|
while j >= 0 and text[j].isspace():
|
||||||
|
j -= 1
|
||||||
|
tok_end = j
|
||||||
|
while j >= 0 and (text[j].isalnum() or text[j] in '_-'):
|
||||||
|
j -= 1
|
||||||
|
subject_start = j if j >= 0 and text[j] == ':' else j + 1
|
||||||
|
subject = text[subject_start:tok_end + 1]
|
||||||
|
out.append(text[pos:subject_start])
|
||||||
|
# lo: from after BETWEEN to the first top-level AND
|
||||||
|
j = m.end()
|
||||||
|
depth = 0
|
||||||
|
and_pos = None
|
||||||
|
k = j
|
||||||
|
while k < len(text):
|
||||||
|
ch = text[k]
|
||||||
|
if ch == '(':
|
||||||
|
depth += 1
|
||||||
|
elif ch == ')':
|
||||||
|
depth -= 1
|
||||||
|
elif depth == 0 and re.match(r'\bAND\b', text[k:], re.IGNORECASE):
|
||||||
|
and_pos = k
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
if and_pos is None:
|
||||||
|
out.append(text[subject_start:m.start()])
|
||||||
|
out.append(text[m.start():])
|
||||||
|
pos = len(text)
|
||||||
|
break
|
||||||
|
lo = text[j:and_pos].strip()
|
||||||
|
# hi: from after AND until the next top-level AND/OR or end
|
||||||
|
k = and_pos + 3
|
||||||
|
hi_end = len(text)
|
||||||
|
depth = 0
|
||||||
|
while k < len(text):
|
||||||
|
ch = text[k]
|
||||||
|
if ch == '(':
|
||||||
|
depth += 1
|
||||||
|
elif ch == ')':
|
||||||
|
depth -= 1
|
||||||
|
elif depth == 0 and re.match(r'\b(?:AND|OR)\b', text[k:], re.IGNORECASE):
|
||||||
|
hi_end = k
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
hi = text[and_pos + 3:hi_end].strip()
|
||||||
|
spans.append({'subject': subject, 'lo': lo, 'hi': hi, 'neg': bool(mnot)})
|
||||||
|
out.append(f"__BTW{len(spans) - 1}__ ")
|
||||||
|
pos = hi_end
|
||||||
|
i = hi_end
|
||||||
|
out.append(text[pos:])
|
||||||
|
return ''.join(out), spans
|
||||||
|
|
||||||
|
|
||||||
|
def _split_on_AND(text: str, spans: list = None, keep_placeholders: bool = False) -> list[str]:
|
||||||
|
"""Split WHERE clause on AND, respecting parentheses.
|
||||||
|
|
||||||
|
BETWEEN ... AND ... spans are protected first so their inner AND is not
|
||||||
|
treated as a separator. When `keep_placeholders` is True (internal use),
|
||||||
|
BETWEEN spans stay as `__BTW{i}__` tokens so the caller can re-parse them.
|
||||||
|
"""
|
||||||
|
if spans is None:
|
||||||
|
text, spans = _scan_between_spans(text)
|
||||||
parts = []
|
parts = []
|
||||||
current = []
|
current = []
|
||||||
depth = 0
|
depth = 0
|
||||||
@@ -52,13 +141,51 @@ def _split_on_AND(text: str) -> list[str]:
|
|||||||
current.append(token)
|
current.append(token)
|
||||||
if current:
|
if current:
|
||||||
parts.append(' '.join(current).strip())
|
parts.append(' '.join(current).strip())
|
||||||
|
if not keep_placeholders:
|
||||||
|
parts = [_restore_between_placeholders(p, spans) for p in parts]
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_between_placeholders(part: str, spans: list) -> str:
|
||||||
|
"""Replace `__BTW{i}__` tokens back with their original BETWEEN text."""
|
||||||
|
def _repl(m):
|
||||||
|
idx = int(m.group(1))
|
||||||
|
if idx >= len(spans):
|
||||||
|
return m.group(0)
|
||||||
|
s = spans[idx]
|
||||||
|
neg = 'NOT ' if s['neg'] else ''
|
||||||
|
return f"{s['subject']} {neg}BETWEEN {s['lo']} AND {s['hi']}"
|
||||||
|
return re.sub(r'__BTW(\d+)__', _repl, part)
|
||||||
|
|
||||||
|
|
||||||
|
_BETWEEN_OP = re.compile(r'(__STR\d+__|:\w[\w-]*|[\w.-]+)')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_between_operand(raw: str, replacements: list) -> dict:
|
||||||
|
"""Parse a BETWEEN subject/lo/hi operand into a kinded dict.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
{'kind': 'host_var', 'name': 'X'}
|
||||||
|
{'kind': 'literal', 'value': '...'}
|
||||||
|
{'kind': 'column', 'name': 'COL'}
|
||||||
|
"""
|
||||||
|
raw = raw.strip().strip('()')
|
||||||
|
if raw.startswith(':'):
|
||||||
|
return {'kind': 'host_var', 'name': raw[1:].upper()}
|
||||||
|
m = re.match(r'__STR(\d+)__$', raw)
|
||||||
|
if m and int(m.group(1)) < len(replacements):
|
||||||
|
return {'kind': 'literal', 'value': replacements[int(m.group(1))].strip("'\"")}
|
||||||
|
if raw.startswith('__STR') and raw.endswith('__'):
|
||||||
|
idx = int(raw[5:-2])
|
||||||
|
if idx < len(replacements):
|
||||||
|
return {'kind': 'literal', 'value': replacements[idx].strip("'\"")}
|
||||||
|
return {'kind': 'column', 'name': raw.upper()}
|
||||||
|
|
||||||
|
|
||||||
# ── WHERE condition parsing ──
|
# ── WHERE condition parsing ──
|
||||||
|
|
||||||
_COL_OP_PAT = re.compile(
|
_COL_OP_PAT = re.compile(
|
||||||
r'(\w[\w.-]*)\s*' # column name (with optional alias prefix)
|
r'(:?\w[\w.-]*)\s*' # column name or host variable (with optional alias prefix)
|
||||||
r'(=|>|<|>=|<=|<>|!=|NOT\s*=)\s*'
|
r'(=|>|<|>=|<=|<>|!=|NOT\s*=)\s*'
|
||||||
r'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)',
|
r'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
@@ -70,7 +197,7 @@ _RE_IN_CLAUSE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_RE_BETWEEN = re.compile(
|
_RE_BETWEEN = re.compile(
|
||||||
r'(\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)',
|
r'(:?\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)',
|
||||||
re.IGNORECASE
|
re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,14 +251,15 @@ def _parse_where_condition(part: str, replacements: list) -> dict | None:
|
|||||||
# BETWEEN
|
# BETWEEN
|
||||||
m = _RE_BETWEEN.match(part)
|
m = _RE_BETWEEN.match(part)
|
||||||
if m:
|
if m:
|
||||||
col = m.group(1).upper()
|
subj = m.group(1).upper()
|
||||||
neg = bool(m.group(2))
|
neg = bool(m.group(2))
|
||||||
lo = m.group(3).strip()
|
lo = m.group(3).strip()
|
||||||
hi = m.group(4).strip()
|
hi = m.group(4).strip()
|
||||||
return {
|
return {
|
||||||
'col': col, 'type': 'between', 'neg': neg,
|
'type': 'between', 'neg': neg, 'op': 'BETWEEN',
|
||||||
'op': 'BETWEEN',
|
'subject': _parse_between_operand(subj, replacements),
|
||||||
'lo': lo.strip("'\""), 'hi': hi.strip("'\""),
|
'lo': _parse_between_operand(lo, replacements),
|
||||||
|
'hi': _parse_between_operand(hi, replacements),
|
||||||
}
|
}
|
||||||
|
|
||||||
# LIKE
|
# LIKE
|
||||||
@@ -216,19 +344,28 @@ def sql_extract_constraints(where_clause: str, table: str,
|
|||||||
# Protect string literals
|
# Protect string literals
|
||||||
cleaned, replacements = _protect_strings(where_clause)
|
cleaned, replacements = _protect_strings(where_clause)
|
||||||
|
|
||||||
# Split on AND
|
# Protect BETWEEN spans, then split on AND
|
||||||
and_parts = _split_on_AND(cleaned)
|
protected, spans = _scan_between_spans(cleaned)
|
||||||
|
and_parts = _split_on_AND(protected, spans=spans, keep_placeholders=True)
|
||||||
|
|
||||||
constraints = []
|
constraints = []
|
||||||
for part in and_parts:
|
for part in and_parts:
|
||||||
part = part.strip()
|
part = part.strip()
|
||||||
if not part:
|
if not part:
|
||||||
continue
|
continue
|
||||||
cond = _parse_where_condition(part, replacements)
|
btw = _parse_between_placeholder(part, spans, replacements)
|
||||||
|
if btw is not None:
|
||||||
|
cond = btw
|
||||||
|
else:
|
||||||
|
cond = _parse_where_condition(part, replacements)
|
||||||
if cond:
|
if cond:
|
||||||
# Map column to COBOL field
|
# Map column to COBOL field
|
||||||
cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map)
|
col_name = cond.get('col') or (cond.get('subject', {}) or {}).get('name')
|
||||||
cond['cobol_field'] = cobol_field
|
if col_name and cond['type'] == 'between':
|
||||||
|
cond['cobol_field'] = guess_cobol_field(col_name, table, declared_columns, column_map)
|
||||||
|
elif cond.get('col'):
|
||||||
|
cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map)
|
||||||
|
cond['cobol_field'] = cobol_field
|
||||||
constraints.append(cond)
|
constraints.append(cond)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unparseable WHERE condition: {_restore_strings(part, replacements)}")
|
logger.warning(f"Unparseable WHERE condition: {_restore_strings(part, replacements)}")
|
||||||
@@ -236,6 +373,29 @@ def sql_extract_constraints(where_clause: str, table: str,
|
|||||||
return constraints
|
return constraints
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_between_placeholder(part: str, spans: list, replacements: list) -> dict | None:
|
||||||
|
"""Parse a BETWEEN span from its `__BTW{i}__` placeholder.
|
||||||
|
|
||||||
|
The part may contain trailing content after the placeholder (e.g. wrapped
|
||||||
|
in parentheses); only the first placeholder token is interpreted. Returns
|
||||||
|
a between constraint dict, or None when the part has no placeholder.
|
||||||
|
"""
|
||||||
|
m = re.search(r'__BTW(\d+)__', part)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
idx = int(m.group(1))
|
||||||
|
if idx >= len(spans):
|
||||||
|
return None
|
||||||
|
s = spans[idx]
|
||||||
|
subject = _parse_between_operand(s['subject'], replacements)
|
||||||
|
return {
|
||||||
|
'type': 'between', 'neg': s['neg'], 'op': 'BETWEEN',
|
||||||
|
'subject': subject,
|
||||||
|
'lo': _parse_between_operand(s['lo'], replacements),
|
||||||
|
'hi': _parse_between_operand(s['hi'], replacements),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── DB input row generation ──
|
# ── DB input row generation ──
|
||||||
|
|
||||||
_COLUMN_DEFAULTS = {
|
_COLUMN_DEFAULTS = {
|
||||||
@@ -251,8 +411,13 @@ _COLUMN_DEFAULTS = {
|
|||||||
|
|
||||||
def _format_db_value(col_info: dict, raw_val: str) -> str:
|
def _format_db_value(col_info: dict, raw_val: str) -> str:
|
||||||
db_type = col_info.get('db_type', 'CHAR')
|
db_type = col_info.get('db_type', 'CHAR')
|
||||||
formatter = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10])
|
if db_type in ('CHAR', 'VARCHAR'):
|
||||||
default = formatter(0)
|
size = col_info.get('size', 1)
|
||||||
|
default = ' ' * size
|
||||||
|
elif db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
|
||||||
|
default = _COLUMN_DEFAULTS.get(db_type, lambda _: '?')(0)
|
||||||
|
else:
|
||||||
|
default = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10])(0)
|
||||||
if raw_val is None:
|
if raw_val is None:
|
||||||
return default
|
return default
|
||||||
if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
|
if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
|
||||||
@@ -261,10 +426,11 @@ def _format_db_value(col_info: dict, raw_val: str) -> str:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
return str(raw_val).ljust(len(default))[:len(default)]
|
return str(raw_val).ljust(len(default))[:len(default)]
|
||||||
|
|
||||||
|
|
||||||
def _make_key_unique(key_val: str, path_index: int, seen_keys: set) -> str:
|
def _make_key_unique(key_val: str, path_index: int, seen_keys: set) -> str:
|
||||||
unique = f"{path_index:03d}{key_val[:5]}"
|
stripped = key_val.strip()
|
||||||
|
if len(stripped) >= 5:
|
||||||
|
return key_val
|
||||||
|
unique = f"{path_index:03d}{stripped[:5]}"
|
||||||
while unique in seen_keys:
|
while unique in seen_keys:
|
||||||
unique = f"{path_index:03d}{hash(key_val) % 100000:05d}"
|
unique = f"{path_index:03d}{hash(key_val) % 100000:05d}"
|
||||||
seen_keys.add(unique)
|
seen_keys.add(unique)
|
||||||
@@ -291,12 +457,18 @@ def collect_sql_meta(assignments: dict, declared_columns: dict,
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
where = asgn.get('where', '')
|
where = asgn.get('where', '')
|
||||||
table = asgn.get('table', '')
|
table = asgn.get('table', '')
|
||||||
|
table = _norm_table(table)
|
||||||
where_constraints = sql_extract_constraints(
|
where_constraints = sql_extract_constraints(
|
||||||
where, table, {}, column_map or {}, declared_columns
|
where, table, {}, column_map or {}, declared_columns
|
||||||
)
|
)
|
||||||
meta = dict(asgn)
|
meta = dict(asgn)
|
||||||
|
meta['table'] = table
|
||||||
meta['where_constraints'] = where_constraints
|
meta['where_constraints'] = where_constraints
|
||||||
sql_meta.append(meta)
|
sql_meta.append(meta)
|
||||||
|
|
||||||
|
# Order by source position so downstream SELECTs come last (reliable
|
||||||
|
# runtime query order). Entries without pos keep their insertion order.
|
||||||
|
sql_meta.sort(key=lambda m: m.get('pos') or 10 ** 9)
|
||||||
return sql_meta
|
return sql_meta
|
||||||
|
|
||||||
|
|
||||||
@@ -311,6 +483,9 @@ def _path_has_sql_ok(path_cons: list) -> bool:
|
|||||||
sql_ok = False
|
sql_ok = False
|
||||||
if pc[1] == '>' and pc[3]:
|
if pc[1] == '>' and pc[3]:
|
||||||
sql_ok = False
|
sql_ok = False
|
||||||
|
# SQLCODE = <non-zero> (want True) means SQL must fail
|
||||||
|
if pc[1] == '=' and pc[3] and str(pc[2]).strip() not in ('0', "'0'"):
|
||||||
|
sql_ok = False
|
||||||
break
|
break
|
||||||
return sql_ok
|
return sql_ok
|
||||||
|
|
||||||
@@ -345,6 +520,367 @@ def _parse_select_columns(select_list: str) -> list[str]:
|
|||||||
return cols
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _rec_get(rec: dict, key: str, default=''):
|
||||||
|
"""rec が flat / {working_storage: ...} 両方の形式をサポートする。"""
|
||||||
|
if key in rec:
|
||||||
|
return str(rec[key])
|
||||||
|
ws = rec.get('working_storage', {})
|
||||||
|
if isinstance(ws, dict) and key in ws:
|
||||||
|
return str(ws[key])
|
||||||
|
inp = rec.get('input', {})
|
||||||
|
if isinstance(inp, dict) and key in inp:
|
||||||
|
return str(inp[key])
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _rec_has(rec: dict, key: str) -> bool:
|
||||||
|
return key in rec or key in rec.get('working_storage', {}) or key in rec.get('input', {})
|
||||||
|
|
||||||
|
def _norm_col(name: str) -> str:
|
||||||
|
"""Normalize a SQL column name for comparisons ('-' and '_' are equivalent)."""
|
||||||
|
return str(name).upper().replace('-', '_')
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_table(table: str) -> str:
|
||||||
|
"""Normalize a SQL table name: strip DB2 schema qualifier (SCHEMA.TABLE → TABLE).
|
||||||
|
|
||||||
|
core.py captures qualified names faithfully (e.g. 'SALARYDB.EMP-MASTER') so
|
||||||
|
the schema part is not lost; here we reduce to the last segment so seeds
|
||||||
|
target the YAML schema table (EMP-MASTER / EMP_MASTER). No-op for plain names.
|
||||||
|
"""
|
||||||
|
return str(table).rsplit('.', 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_cols_for(declared_columns: dict, table: str) -> list[dict]:
|
||||||
|
"""Look up declared columns for a SQL table, tolerating '-'/'_' naming.
|
||||||
|
|
||||||
|
YAML schema registers tables with underscores (EMP_MASTER) while SQL
|
||||||
|
references may use hyphens (EMP-MASTER); missing this fallback makes
|
||||||
|
build_db_input fall back to 10-char inferred sizes, padding stored keys
|
||||||
|
with trailing spaces so runtime '=' lookups with unpadded host vars fail.
|
||||||
|
"""
|
||||||
|
cols = declared_columns.get(table, [])
|
||||||
|
if not cols and '-' in table:
|
||||||
|
cols = declared_columns.get(table.replace('-', '_'), [])
|
||||||
|
elif not cols and '_' in table:
|
||||||
|
cols = declared_columns.get(table.replace('_', '-'), [])
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _hostvar_root(host_var: str, assignments: dict) -> str:
|
||||||
|
"""Trace a SQL WHERE host var through MOVE assignments to its source root.
|
||||||
|
|
||||||
|
e.g. assignments['DBV-EMP-ID'] = [{'type': 'move', 'source_vars': ['R02EMP-ID']}]
|
||||||
|
→ root for DBV-EMP-ID is R02EMP-ID. Handles multi-hop MOVE chains.
|
||||||
|
Returns the original host_var when no MOVE chain applies.
|
||||||
|
"""
|
||||||
|
seen = set()
|
||||||
|
cur = host_var
|
||||||
|
while cur in assignments and cur not in seen:
|
||||||
|
seen.add(cur)
|
||||||
|
al = assignments[cur]
|
||||||
|
if isinstance(al, dict):
|
||||||
|
al = [al]
|
||||||
|
if not al:
|
||||||
|
break
|
||||||
|
last = al[-1]
|
||||||
|
if last.get('type') == 'move' and last.get('source_vars'):
|
||||||
|
cur = str(last['source_vars'][0]).upper()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _rec_get_ci(rec: dict, name: str):
|
||||||
|
"""Case-insensitive _rec_get. Returns the value string or None if not found."""
|
||||||
|
for scope in (rec, rec.get('working_storage', {}), rec.get('input', {})):
|
||||||
|
for k, v in (scope or {}).items():
|
||||||
|
if str(k).upper() == name.upper():
|
||||||
|
return str(v)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_hostvar(rec: dict, host_var: str, assignments: dict) -> str:
|
||||||
|
"""Resolve a host var to the value the program actually uses at runtime.
|
||||||
|
|
||||||
|
Returns only runtime-deterministic values:
|
||||||
|
1. The literal default in the root field's assignment chain when the root
|
||||||
|
is fed by `ACCEPT ... FROM COMMAND-LINE` (e.g. `IF x = SPACES
|
||||||
|
MOVE '202605' TO x`). The pipeline runs programs without command-line
|
||||||
|
args, so that SPACES default is what the program actually uses at
|
||||||
|
runtime (DBV-YEAR-MONTH -> ... -> WRK-YEAR-MONTH = '202605').
|
||||||
|
2. The input-record field value at the end of a MOVE chain
|
||||||
|
(e.g. `MOVE R02EMP-ID TO DBV-EMP-ID` -> R02EMP-ID's record value).
|
||||||
|
|
||||||
|
Returns '' when no deterministic runtime value is derivable; the caller
|
||||||
|
falls back to record heuristics.
|
||||||
|
"""
|
||||||
|
if not assignments:
|
||||||
|
return ''
|
||||||
|
root = _hostvar_root(host_var, assignments)
|
||||||
|
al = assignments.get(root)
|
||||||
|
if isinstance(al, dict):
|
||||||
|
al = [al]
|
||||||
|
if al and any(a.get('type') == 'accept' for a in al):
|
||||||
|
for a in al:
|
||||||
|
if a.get('type') == 'move_literal' and a.get('literal') is not None:
|
||||||
|
return str(a['literal'])
|
||||||
|
if root != host_var:
|
||||||
|
rv = _rec_get_ci(rec, root)
|
||||||
|
if rv is not None and str(rv).strip():
|
||||||
|
return str(rv)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_where_hostvar(rec: dict, host_var: str, assignments: dict):
|
||||||
|
"""Resolve a WHERE host var to the value the program actually uses.
|
||||||
|
|
||||||
|
If the host var is set by MOVE assignments (e.g. `MOVE R02EMP-ID TO
|
||||||
|
DBV-EMP-ID`) before the SQL statement, trace to the input-record root and
|
||||||
|
prefer its value, so the DB pre-seed matches the runtime query key.
|
||||||
|
Falls back to the host var's own record value when no chain resolves.
|
||||||
|
"""
|
||||||
|
root = _hostvar_root(host_var, assignments)
|
||||||
|
if root != host_var:
|
||||||
|
rv = _rec_get_ci(rec, root)
|
||||||
|
if rv is not None and str(rv).strip():
|
||||||
|
return str(rv)
|
||||||
|
return _rec_get(rec, host_var, '')
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_or_where_hostvar(rec: dict, host_var: str, assignments: dict) -> str:
|
||||||
|
"""Resolve a WHERE host var preferring the deterministic runtime value.
|
||||||
|
|
||||||
|
`_resolve_runtime_hostvar` handles host vars fed by `ACCEPT ... FROM
|
||||||
|
COMMAND-LINE` whose SPACES default the program substitutes at runtime
|
||||||
|
(e.g. WRK-YEAR-MONTH = '202605'): the record carries a synthetic value
|
||||||
|
('I00001') that does NOT match the runtime query, so without this the
|
||||||
|
seeded WHERE columns (EFFECTIVE-FROM/TO) fail the runtime predicate and
|
||||||
|
the rows never load. Falls back to the existing MOVE-chain / record
|
||||||
|
resolution when no deterministic runtime value exists.
|
||||||
|
"""
|
||||||
|
rv = _resolve_runtime_hostvar(rec, host_var, assignments)
|
||||||
|
if rv:
|
||||||
|
return rv
|
||||||
|
return _resolve_where_hostvar(rec, host_var, assignments)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_between_operand(op: dict, rec: dict, assignments: dict):
|
||||||
|
"""Resolve a BETWEEN subject/lo/hi operand to a concrete string value."""
|
||||||
|
if not op:
|
||||||
|
return None
|
||||||
|
kind = op.get('kind')
|
||||||
|
if kind == 'host_var':
|
||||||
|
v = _resolve_where_hostvar(rec, op.get('name', ''), assignments)
|
||||||
|
return v
|
||||||
|
if kind == 'literal':
|
||||||
|
return op.get('value')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_runtime_hostvar(rec: dict, host_var: str, assignments: dict,
|
||||||
|
fields: list) -> str | None:
|
||||||
|
"""Derive the runtime value of a WORKING-STORAGE host var via propagation.
|
||||||
|
|
||||||
|
Records generated for coverage carry synthetic WRK-* values (e.g.
|
||||||
|
WRK-TAXABLE-INCOME='000003701') that do NOT match what the program
|
||||||
|
computes at runtime from the input keys (e.g. max(0, gross - deduction)).
|
||||||
|
For a BETWEEN seed to match the runtime query key, we re-derive the value
|
||||||
|
on a copy of the record: seed WORKING-STORAGE constants from their field
|
||||||
|
VALUE clause, then run propagate_assignments. Returns None when the host
|
||||||
|
var is not derivable (e.g. it is an input key with a direct MOVE chain).
|
||||||
|
"""
|
||||||
|
if not assignments or not fields:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from .core import propagate_assignments
|
||||||
|
probe = dict(rec)
|
||||||
|
for f in fields:
|
||||||
|
if f.get('value') is not None and f.get('section') == 'WORKING-STORAGE':
|
||||||
|
probe.setdefault(f['name'], str(f['value']))
|
||||||
|
propagate_assignments(probe, assignments, fields)
|
||||||
|
v = probe.get(host_var)
|
||||||
|
if v is not None and str(v).strip():
|
||||||
|
return str(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _between_seed_value(wc: dict, rec: dict, assignments: dict, col_name: str,
|
||||||
|
fields: list = None) -> str | None:
|
||||||
|
"""Compute the DB column value for a BETWEEN where-constraint.
|
||||||
|
|
||||||
|
Case A (`:hv BETWEEN LO-COL AND HI-COL`): the subject is a host var whose
|
||||||
|
runtime value must fall inside the band; seed LO-COL / HI-COL with it.
|
||||||
|
Case B (`SUBJ-COL BETWEEN :lo AND :hi`): the subject column is the one to
|
||||||
|
seed; use the lo operand value so `lo <= col <= hi` holds.
|
||||||
|
Returns None when this column is not governed by the BETWEEN constraint.
|
||||||
|
"""
|
||||||
|
if not wc or wc.get('type') != 'between' or wc.get('neg'):
|
||||||
|
return None
|
||||||
|
subject = wc.get('subject', {})
|
||||||
|
lo = wc.get('lo', {})
|
||||||
|
hi = wc.get('hi', {})
|
||||||
|
if subject.get('kind') == 'host_var':
|
||||||
|
for side in (lo, hi):
|
||||||
|
if side.get('kind') == 'column' and _norm_col(side.get('name', '')) == _norm_col(col_name):
|
||||||
|
derived = _derive_runtime_hostvar(rec, subject.get('name', ''),
|
||||||
|
assignments, fields or [])
|
||||||
|
if derived:
|
||||||
|
return derived
|
||||||
|
v = _resolve_between_operand(subject, rec, assignments)
|
||||||
|
if v and str(v).strip():
|
||||||
|
return str(v)
|
||||||
|
elif subject.get('kind') == 'column' and _norm_col(subject.get('name', '')) == _norm_col(col_name):
|
||||||
|
lo_val = _resolve_between_operand(lo, rec, assignments)
|
||||||
|
hi_val = _resolve_between_operand(hi, rec, assignments)
|
||||||
|
for v in (lo_val, hi_val):
|
||||||
|
if v is not None and str(v).strip():
|
||||||
|
return str(v)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _input_pk_field(rec: dict, col_name: str) -> str | None:
|
||||||
|
"""Find the input-record field (R##-prefixed) that feeds a PK column.
|
||||||
|
|
||||||
|
Only returns a field whose name ends with the hyphen-normalized column
|
||||||
|
name. A YEAR-MONTH PK column therefore no longer falls back to the
|
||||||
|
EMP-ID input field (which produced a truncated, wrong collision value).
|
||||||
|
Returns None when no such input field exists (e.g. YEAR-MONTH comes from
|
||||||
|
a WORKING-STORAGE ACCEPT default instead).
|
||||||
|
"""
|
||||||
|
if not rec:
|
||||||
|
return None
|
||||||
|
base = col_name.upper().replace('EMP_ID', 'EMP-ID')
|
||||||
|
for key in rec:
|
||||||
|
k = str(key).upper()
|
||||||
|
if k.endswith(base) and k[:1] == 'R' and k[1:2].isdigit():
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_collision_row(sql: dict, rec: dict, pk_cols: list[str],
|
||||||
|
declared_columns: dict, assignments: dict = None) -> dict | None:
|
||||||
|
"""Build a DB pre-seed row whose PK collides with the input record's PK.
|
||||||
|
|
||||||
|
For an INSERT statement, this creates a row with the same primary-key value
|
||||||
|
as the value the program actually INSERTs at runtime, so the runtime INSERT
|
||||||
|
fails with a duplicate-key SQL error (DB2 -803).
|
||||||
|
Returns {} if no PK value could be derived from the record.
|
||||||
|
"""
|
||||||
|
if not rec or not pk_cols:
|
||||||
|
return None
|
||||||
|
columns = sql.get('columns', [])
|
||||||
|
host_vars = sql.get('host_vars', [])
|
||||||
|
if not columns or not host_vars:
|
||||||
|
return None
|
||||||
|
col_infos = declared_columns.get(sql.get('table', ''), [])
|
||||||
|
if not col_infos:
|
||||||
|
# Schema may declare the table with underscores while SQL uses hyphens.
|
||||||
|
col_infos = declared_columns.get(sql.get('table', '').replace('-', '_'), [])
|
||||||
|
if not col_infos:
|
||||||
|
# Fall back to CHAR types inferred from the INSERT column list.
|
||||||
|
col_infos = [{'name': c, 'db_type': 'CHAR', 'size': 20} for c in columns]
|
||||||
|
|
||||||
|
col_to_hv = {}
|
||||||
|
for c, hv in zip(columns, host_vars):
|
||||||
|
col_to_hv[c.upper()] = hv
|
||||||
|
col_to_hv[c.upper().replace('-', '_')] = hv
|
||||||
|
row = {}
|
||||||
|
for ci in col_infos:
|
||||||
|
col_name = ci['name'].upper()
|
||||||
|
col_name_alt = col_name.replace('-', '_') if '-' in col_name else col_name.replace('_', '-')
|
||||||
|
if col_name not in pk_cols and col_name_alt not in pk_cols:
|
||||||
|
continue
|
||||||
|
pk_hit = col_name if col_name in pk_cols else col_name_alt
|
||||||
|
hv = col_to_hv.get(col_name) or col_to_hv.get(col_name_alt)
|
||||||
|
val = None
|
||||||
|
# Prefer the runtime value the program actually INSERTs:
|
||||||
|
# - EMP-ID <- MOVE chain to the input-record field (R02EMP-ID)
|
||||||
|
# - YEAR-MONTH <- ACCEPT + IF-SPACES default literal ('202605')
|
||||||
|
if hv:
|
||||||
|
val = _resolve_runtime_hostvar(rec, hv, assignments or {})
|
||||||
|
if not val or not str(val).strip():
|
||||||
|
inp_field = _input_pk_field(rec, pk_hit)
|
||||||
|
if inp_field:
|
||||||
|
v = _rec_get(rec, inp_field, '')
|
||||||
|
if v and str(v).strip():
|
||||||
|
val = str(v)
|
||||||
|
if (not val or not str(val).strip()) and hv:
|
||||||
|
val = _rec_get(rec, hv, '')
|
||||||
|
if not val or not str(val).strip():
|
||||||
|
return None
|
||||||
|
row[col_name] = _format_db_value(ci, str(val))
|
||||||
|
return row if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 事务调度感知:按 88 级识别记录运行时执行的 SQL 种类 ──
|
||||||
|
# 目的:UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行,
|
||||||
|
# 避免把 INSERT 记录的主键也预置进表(否则运行时 INSERT 全部 -803,
|
||||||
|
# INSERT 成功分支不可达)。通用实现(88 级名 INSERT/UPDATE/DELETE 语义标记、
|
||||||
|
# 沿 MOVE 链追溯输入根字段),无程序名硬编码。
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_field_and_sets(fields_dict):
|
||||||
|
"""返回 (调度字段名, {INSERT:[...], UPDATE:[...], DELETE:[...]}) 或 (None, {})。
|
||||||
|
|
||||||
|
调度字段 = 带 INSERT/UPDATE/DELETE 88 级语义标记的父字段(如 WRK-TRAN-TYPE)。
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
for f in fields_dict or []:
|
||||||
|
if isinstance(f, dict) and f.get('is_88') and f.get('parent'):
|
||||||
|
groups.setdefault(f['parent'], []).append(f)
|
||||||
|
for parent, kids in groups.items():
|
||||||
|
hi = any('INSERT' in str(k.get('name', '')).upper() for k in kids)
|
||||||
|
hu = any('UPDATE' in str(k.get('name', '')).upper() for k in kids)
|
||||||
|
hd = any('DELETE' in str(k.get('name', '')).upper() for k in kids)
|
||||||
|
if hi and hu and hd:
|
||||||
|
sets = {}
|
||||||
|
for k in kids:
|
||||||
|
nm = str(k.get('name', '')).upper()
|
||||||
|
vals = k.get('values') or ([k.get('value')] if k.get('value') else [])
|
||||||
|
group = next((g for g in ('INSERT', 'UPDATE', 'DELETE') if g in nm), None)
|
||||||
|
if not group:
|
||||||
|
continue
|
||||||
|
for v in vals:
|
||||||
|
sets.setdefault(group, set()).add(str(v).strip())
|
||||||
|
return parent, sets
|
||||||
|
return None, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_input_root(field_name, assignments):
|
||||||
|
"""沿 MOVE 链把调度字段追溯到输入记录根字段(WRK-TRAN-TYPE ← R01TRAN-TYPE)。"""
|
||||||
|
seen = set()
|
||||||
|
cur = field_name
|
||||||
|
while cur and cur not in seen:
|
||||||
|
seen.add(cur)
|
||||||
|
al = assignments.get(cur) or []
|
||||||
|
if not al:
|
||||||
|
break
|
||||||
|
srcs = al[0].get('source_vars') or []
|
||||||
|
if len(srcs) != 1:
|
||||||
|
break
|
||||||
|
cur = srcs[0]
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_record_dispatches(records, fields_dict, assignments):
|
||||||
|
"""返回 {path_idx: 'INSERT'|'UPDATE'|'DELETE'|'OTHER'}。
|
||||||
|
|
||||||
|
用记录的实际调度值(输入根字段,非合成工作区值)对照 88 级集合分类。
|
||||||
|
"""
|
||||||
|
field, sets = _dispatch_field_and_sets(fields_dict)
|
||||||
|
if not field:
|
||||||
|
return {}
|
||||||
|
root = _trace_input_root(field, assignments or {})
|
||||||
|
out = {}
|
||||||
|
for i, r in enumerate(records or []):
|
||||||
|
v = str(r.get(root, r.get(field, ''))).strip()
|
||||||
|
out[i] = next((k for k in ('INSERT', 'UPDATE', 'DELETE')
|
||||||
|
if v in sets.get(k, set())), 'OTHER')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_db_input(
|
def build_db_input(
|
||||||
branch_paths: list[tuple[list, dict]],
|
branch_paths: list[tuple[list, dict]],
|
||||||
fields_dict: list[dict],
|
fields_dict: list[dict],
|
||||||
@@ -352,6 +888,7 @@ def build_db_input(
|
|||||||
sql_meta: list[dict],
|
sql_meta: list[dict],
|
||||||
declared_columns: dict,
|
declared_columns: dict,
|
||||||
records: list[dict] = None,
|
records: list[dict] = None,
|
||||||
|
insert_pk: dict[str, list[str]] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Generate DB input rows per branch path.
|
"""Generate DB input rows per branch path.
|
||||||
Returns {table: [{col: val, ...}, ...]}.
|
Returns {table: [{col: val, ...}, ...]}.
|
||||||
@@ -363,14 +900,42 @@ def build_db_input(
|
|||||||
seen_keys = {}
|
seen_keys = {}
|
||||||
seq_counter = itertools.count(1)
|
seq_counter = itertools.count(1)
|
||||||
|
|
||||||
|
# 事务调度感知:分类每条记录运行时执行的 SQL(INSERT/UPDATE/DELETE/OTHER)。
|
||||||
|
# UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行,避免把 INSERT
|
||||||
|
# 记录主键预置进表导致 INSERT 成功分支不可达。
|
||||||
|
dispatch = _classify_record_dispatches(records, fields_dict, assignments)
|
||||||
|
|
||||||
|
# Downstream no-data coverage: when a path seeds rows for multiple SELECT
|
||||||
|
# tables (e.g. EMP-MASTER then OVT-MONTHLY), drop the rows of the LAST
|
||||||
|
# (runtime-order) table for the LAST SQL-ok path. That record then reaches
|
||||||
|
# the downstream SELECT with no matching row (SQLCODE = 100), covering the
|
||||||
|
# downstream SELECT's "no data" branch (IF SQLCODE = 0 ELSE path).
|
||||||
|
sql_select_tables = [m.get('table') for m in sql_meta
|
||||||
|
if m.get('type') == 'exec_sql_select' and m.get('table')]
|
||||||
|
unique_select_tables = []
|
||||||
|
for t in sql_select_tables:
|
||||||
|
if t not in unique_select_tables:
|
||||||
|
unique_select_tables.append(t)
|
||||||
|
drop_table = unique_select_tables[-1] if len(unique_select_tables) >= 2 else None
|
||||||
|
sql_ok_paths = [i for i, (pc, _pa) in enumerate(branch_paths) if _path_has_sql_ok(pc)]
|
||||||
|
last_sql_ok_path = sql_ok_paths[-1] if len(sql_ok_paths) >= 2 else -1
|
||||||
|
|
||||||
# Collect all SQL meta per path
|
# Collect all SQL meta per path
|
||||||
for path_idx, (path_cons, path_assign) in enumerate(branch_paths):
|
for path_idx, (path_cons, path_assign) in enumerate(branch_paths):
|
||||||
# Skip paths where SQL fails (SQLCODE <> 0)
|
sql_ok = _path_has_sql_ok(path_cons)
|
||||||
if not _path_has_sql_ok(path_cons):
|
|
||||||
continue
|
|
||||||
|
|
||||||
rec = records[path_idx] if records and path_idx < len(records) else {}
|
rec = records[path_idx] if records and path_idx < len(records) else {}
|
||||||
|
|
||||||
|
if path_idx == 0 and rec:
|
||||||
|
with open(r'C:\Users\marye\AppData\Local\Temp\opencode\build_db_input_debug.txt', 'w') as _f:
|
||||||
|
_f.write(f"rec keys count={len(rec)}\n")
|
||||||
|
_f.write(f"has_HV-ANNUAL-H={'HV-ANNUAL-H' in rec}\n")
|
||||||
|
_f.write(f"has_working_storage={'working_storage' in rec}\n")
|
||||||
|
_f.write(f"HV-ANNUAL-H via _rec_get={_rec_get(rec, 'HV-ANNUAL-H', 'NOT_FOUND')!r}\n")
|
||||||
|
if 'HV-ANNUAL-H' in rec:
|
||||||
|
_f.write(f"HV-ANNUAL-H value={rec['HV-ANNUAL-H']!r}\n")
|
||||||
|
_f.write(f"all keys sorted={sorted(rec.keys())}\n")
|
||||||
|
|
||||||
for sql in sql_meta:
|
for sql in sql_meta:
|
||||||
atype = sql.get('type', '')
|
atype = sql.get('type', '')
|
||||||
table = sql.get('table', '')
|
table = sql.get('table', '')
|
||||||
@@ -383,12 +948,36 @@ def build_db_input(
|
|||||||
seen_keys[table] = set()
|
seen_keys[table] = set()
|
||||||
|
|
||||||
if atype == 'exec_sql_insert':
|
if atype == 'exec_sql_insert':
|
||||||
# INSERT creates rows at runtime; no initial rows needed
|
# INSERT creates rows at runtime; no initial rows needed,
|
||||||
|
# EXCEPT a PK-collision row so the duplicate-key error path
|
||||||
|
# (SQLCODE <> 0 / -803) is reachable at runtime.
|
||||||
|
if insert_pk:
|
||||||
|
pk_cols = [c.upper() for c in insert_pk.get(table, [])]
|
||||||
|
if pk_cols and not _path_has_sql_ok(path_cons):
|
||||||
|
# 碰撞行必须命中真实执行 INSERT 的记录:若本路径记录并非
|
||||||
|
# INSERT 类(如合成记录走了 WHEN OTHER),回退到首条
|
||||||
|
# INSERT 类记录的主键,确保运行时 -803 分支可达。
|
||||||
|
target = rec if dispatch.get(path_idx) == 'INSERT' else None
|
||||||
|
if target is None:
|
||||||
|
ins_idx = next((i for i in sorted(dispatch)
|
||||||
|
if dispatch[i] == 'INSERT'), None)
|
||||||
|
target = records[ins_idx] if ins_idx is not None else rec
|
||||||
|
row = _insert_collision_row(sql, target, pk_cols,
|
||||||
|
declared_columns,
|
||||||
|
assignments=assignments)
|
||||||
|
if row:
|
||||||
|
db_input[table].append(row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if atype in ('exec_sql_delete', 'exec_sql_update'):
|
if atype in ('exec_sql_delete', 'exec_sql_update'):
|
||||||
# DELETE/UPDATE needs existing rows to act on
|
# DELETE/UPDATE needs existing rows to act on
|
||||||
col_infos = declared_columns.get(table, [])
|
if not sql_ok:
|
||||||
|
continue
|
||||||
|
# 仅对实际执行 UPDATE/DELETE 的记录建行(避免污染 INSERT/OTHER 主键)。
|
||||||
|
# 无 88 级调度字段可识别时不做门控(回退旧行为)。
|
||||||
|
if dispatch and dispatch.get(path_idx) not in ('UPDATE', 'DELETE'):
|
||||||
|
continue
|
||||||
|
col_infos = _declared_cols_for(declared_columns, table)
|
||||||
if not col_infos:
|
if not col_infos:
|
||||||
col_infos = _infer_columns_from_where(where_cons)
|
col_infos = _infer_columns_from_where(where_cons)
|
||||||
row = {}
|
row = {}
|
||||||
@@ -397,17 +986,17 @@ def build_db_input(
|
|||||||
val = None
|
val = None
|
||||||
for wc in where_cons:
|
for wc in where_cons:
|
||||||
wc_col = wc.get('col', '').upper().split('.')[-1]
|
wc_col = wc.get('col', '').upper().split('.')[-1]
|
||||||
if wc_col != col_name:
|
if _norm_col(wc_col) != _norm_col(col_name):
|
||||||
continue
|
continue
|
||||||
if wc['type'] == 'literal':
|
if wc['type'] == 'literal':
|
||||||
val = wc.get('literal', '')
|
val = wc.get('literal', '')
|
||||||
break
|
break
|
||||||
elif wc['type'] == 'host_var':
|
elif wc['type'] == 'host_var':
|
||||||
hv = wc.get('host_var', '').upper()
|
hv = wc.get('host_var', '').upper()
|
||||||
val = str(rec.get(hv, ''))
|
val = _runtime_or_where_hostvar(rec, hv, assignments)
|
||||||
break
|
break
|
||||||
if val is None or not val.strip():
|
if val is None or not val.strip():
|
||||||
val = str(rec.get(ci['name'], ''))
|
val = _rec_get(rec, ci['name'], '')
|
||||||
if val and val.strip():
|
if val and val.strip():
|
||||||
row[ci['name']] = _format_db_value(ci, val)
|
row[ci['name']] = _format_db_value(ci, val)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -416,8 +1005,10 @@ def build_db_input(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# exec_sql_select (and any future read-only types)
|
# exec_sql_select (and any future read-only types)
|
||||||
|
if not sql_ok:
|
||||||
|
continue
|
||||||
row = {}
|
row = {}
|
||||||
col_infos = declared_columns.get(table, [])
|
col_infos = _declared_cols_for(declared_columns, table)
|
||||||
if not col_infos:
|
if not col_infos:
|
||||||
col_infos = _infer_columns_from_where(where_cons)
|
col_infos = _infer_columns_from_where(where_cons)
|
||||||
into_vars = sql.get('into_vars', [])
|
into_vars = sql.get('into_vars', [])
|
||||||
@@ -431,7 +1022,7 @@ def build_db_input(
|
|||||||
|
|
||||||
# Use SQL column names (not INTO var names) for row keys
|
# Use SQL column names (not INTO var names) for row keys
|
||||||
for sc in select_cols:
|
for sc in select_cols:
|
||||||
if sc not in [c['name'] for c in col_infos]:
|
if not any(_norm_col(sc) == _norm_col(c['name']) for c in col_infos):
|
||||||
col_infos.append({'name': sc, 'db_type': 'CHAR', 'size': 20})
|
col_infos.append({'name': sc, 'db_type': 'CHAR', 'size': 20})
|
||||||
|
|
||||||
where_cols = set()
|
where_cols = set()
|
||||||
@@ -439,35 +1030,48 @@ def build_db_input(
|
|||||||
col_name = col_info['name']
|
col_name = col_info['name']
|
||||||
val = None
|
val = None
|
||||||
for wc in where_cons:
|
for wc in where_cons:
|
||||||
if wc['type'] == 'literal' and wc.get('col', '').upper() == col_name:
|
if wc['type'] == 'between':
|
||||||
|
bv = _between_seed_value(wc, rec, assignments, col_name,
|
||||||
|
fields_dict)
|
||||||
|
if bv is not None:
|
||||||
|
val = bv
|
||||||
|
where_cols.add(col_name)
|
||||||
|
break
|
||||||
|
if wc['type'] == 'literal' and _norm_col(wc.get('col', '')) == _norm_col(col_name):
|
||||||
val = wc.get('literal', '')
|
val = wc.get('literal', '')
|
||||||
where_cols.add(col_name)
|
where_cols.add(col_name)
|
||||||
break
|
break
|
||||||
if wc['type'] == 'host_var':
|
if wc['type'] == 'host_var':
|
||||||
|
wc_col = wc.get('col', '').upper().split('.')[-1]
|
||||||
|
if _norm_col(wc_col) != _norm_col(col_name):
|
||||||
|
continue
|
||||||
hv = wc.get('host_var', '').upper()
|
hv = wc.get('host_var', '').upper()
|
||||||
for pc_field, pc_op, pc_val, pc_want in path_cons:
|
for pc_field, pc_op, pc_val, pc_want in path_cons:
|
||||||
if pc_field == hv:
|
if pc_field == hv:
|
||||||
val = pc_val if pc_want else ''
|
val = pc_val if pc_want else ''
|
||||||
|
where_cols.add(col_name)
|
||||||
break
|
break
|
||||||
if val is None and hv in rec:
|
if val is None and _rec_has(rec, hv):
|
||||||
val = str(rec[hv])
|
val = _runtime_or_where_hostvar(rec, hv, assignments)
|
||||||
|
where_cols.add(col_name)
|
||||||
|
|
||||||
# Try to find value from INTO variable in the record
|
# Try to find value from INTO variable in the record
|
||||||
if val is None:
|
if val is None:
|
||||||
for iv, scola in into_to_col.items():
|
for iv, scola in into_to_col.items():
|
||||||
if scola == col_name and iv in rec:
|
if _norm_col(scola) == _norm_col(col_name) and _rec_has(rec, iv):
|
||||||
val = str(rec[iv])
|
val = _rec_get(rec, iv, '')
|
||||||
break
|
break
|
||||||
|
|
||||||
# Try COBOL field name mapping
|
# Try COBOL field name mapping
|
||||||
if val is None:
|
if val is None:
|
||||||
cobol_field = guess_cobol_field(col_name, table, declared_columns)
|
cobol_field = guess_cobol_field(col_name, table, declared_columns)
|
||||||
if cobol_field in rec:
|
if _rec_has(rec, cobol_field):
|
||||||
val = str(rec[cobol_field])
|
val = _rec_get(rec, cobol_field, '')
|
||||||
|
|
||||||
if val is not None:
|
if val is not None:
|
||||||
row[col_name] = _format_db_value(col_info, val)
|
row[col_name] = _format_db_value(col_info, val)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
row[col_name] = _format_db_value(col_info, str(next(seq_counter)))
|
row[col_name] = _format_db_value(col_info, str(next(seq_counter)))
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
@@ -478,6 +1082,8 @@ def build_db_input(
|
|||||||
if first_col in row and (not where_cols or first_col not in where_cols):
|
if first_col in row and (not where_cols or first_col not in where_cols):
|
||||||
row[first_col] = _make_key_unique(row[first_col], path_idx, seen_keys[table])
|
row[first_col] = _make_key_unique(row[first_col], path_idx, seen_keys[table])
|
||||||
|
|
||||||
|
if drop_table is not None and table == drop_table and path_idx == last_sql_ok_path:
|
||||||
|
continue
|
||||||
db_input[table].append(row)
|
db_input[table].append(row)
|
||||||
|
|
||||||
return db_input
|
return db_input
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class SysinDef:
|
|||||||
period: str | None = "202607"
|
period: str | None = "202607"
|
||||||
include_invalid_period: bool = False
|
include_invalid_period: bool = False
|
||||||
modes: list[str] = field(default_factory=lambda: ["NORMAL"])
|
modes: list[str] = field(default_factory=lambda: ["NORMAL"])
|
||||||
|
final_mode: str = "RESET"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -42,6 +43,9 @@ class ScenarioDef:
|
|||||||
inject_duplicate_pk: bool = False
|
inject_duplicate_pk: bool = False
|
||||||
row_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
|
row_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||||
delete_all_rows: bool = False
|
delete_all_rows: bool = False
|
||||||
|
drop_tables: list[str] = field(default_factory=list)
|
||||||
|
command_line: str | None = None
|
||||||
|
seed_extra_rows: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -53,6 +57,7 @@ class ProgramSchema:
|
|||||||
db_name: str = "OVERTIME.DB"
|
db_name: str = "OVERTIME.DB"
|
||||||
runs: list[ScenarioDef] = field(default_factory=list)
|
runs: list[ScenarioDef] = field(default_factory=list)
|
||||||
coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
||||||
|
command_line: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: str | Path) -> ProgramSchema:
|
def from_yaml(cls, path: str | Path) -> ProgramSchema:
|
||||||
@@ -76,6 +81,9 @@ class ProgramSchema:
|
|||||||
inject_duplicate_pk=r.get("inject_duplicate_pk", False),
|
inject_duplicate_pk=r.get("inject_duplicate_pk", False),
|
||||||
row_overrides=r.get("row_overrides", {}),
|
row_overrides=r.get("row_overrides", {}),
|
||||||
delete_all_rows=r.get("delete_all_rows", False),
|
delete_all_rows=r.get("delete_all_rows", False),
|
||||||
|
drop_tables=r.get("drop_tables", []),
|
||||||
|
command_line=r.get("command_line"),
|
||||||
|
seed_extra_rows=r.get("seed_extra_rows", {}),
|
||||||
))
|
))
|
||||||
return cls(
|
return cls(
|
||||||
program_id=raw["program_id"],
|
program_id=raw["program_id"],
|
||||||
@@ -85,6 +93,7 @@ class ProgramSchema:
|
|||||||
db_name=raw.get("db_name", "OVERTIME.DB"),
|
db_name=raw.get("db_name", "OVERTIME.DB"),
|
||||||
runs=runs,
|
runs=runs,
|
||||||
coverage_dates=raw.get("coverage_dates", {}),
|
coverage_dates=raw.get("coverage_dates", {}),
|
||||||
|
command_line=raw.get("command_line"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
program_id: JIN05UPD
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: JIN.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: EMPLOYEE
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: KANA_SEI
|
||||||
|
type: VARCHAR(20)
|
||||||
|
- name: KANA_MEI
|
||||||
|
type: VARCHAR(20)
|
||||||
|
- name: KANJI_NAME
|
||||||
|
type: VARCHAR(40)
|
||||||
|
- name: SUB_CODE
|
||||||
|
type: CHAR(4)
|
||||||
|
- name: BIRTH_DATE
|
||||||
|
type: CHAR(8)
|
||||||
|
- name: DEPT_CODE
|
||||||
|
type: CHAR(4)
|
||||||
|
- name: ENTRY_DATE
|
||||||
|
type: CHAR(8)
|
||||||
|
- name: STATUS
|
||||||
|
type: CHAR(1)
|
||||||
|
|
||||||
|
runs:
|
||||||
|
- id: normal
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB03END
|
||||||
@@ -23,6 +23,22 @@ db_tables:
|
|||||||
- name: STATUS
|
- name: STATUS
|
||||||
type: CHAR(1)
|
type: CHAR(1)
|
||||||
|
|
||||||
|
command_line: YEARMONTH=202607
|
||||||
|
|
||||||
|
runs:
|
||||||
|
- id: normal
|
||||||
|
- id: no_parm
|
||||||
|
command_line: ""
|
||||||
|
- id: feb
|
||||||
|
command_line: YEARMONTH=202602
|
||||||
|
- id: feb_leap
|
||||||
|
command_line: YEARMONTH=202402
|
||||||
|
- id: invalid_month
|
||||||
|
command_line: YEARMONTH=202613
|
||||||
|
- id: empty_emp
|
||||||
|
command_line: YEARMONTH=202607
|
||||||
|
delete_all_rows: true
|
||||||
|
|
||||||
subprograms:
|
subprograms:
|
||||||
- SUB02MSG
|
- SUB02MSG
|
||||||
- SUB03END
|
- SUB03END
|
||||||
|
|||||||
@@ -77,3 +77,22 @@ runs:
|
|||||||
include_invalid_period: true
|
include_invalid_period: true
|
||||||
modes: ["NORMAL", "RESET"]
|
modes: ["NORMAL", "RESET"]
|
||||||
inject_duplicate_pk: true
|
inject_duplicate_pk: true
|
||||||
|
|
||||||
|
- id: sql_delete_error
|
||||||
|
# RESET 時 DELETE MONTHLY_ABSENCE の SQL エラー(テーブル削除)→ 9100DBERRSOR
|
||||||
|
sysin:
|
||||||
|
period: "202607"
|
||||||
|
include_invalid_period: true
|
||||||
|
modes: ["RESET"]
|
||||||
|
inject_duplicate_pk: false
|
||||||
|
drop_tables: [MONTHLY_ABSENCE]
|
||||||
|
|
||||||
|
- id: sql_select_error
|
||||||
|
# UPSERT 前 SELECT COUNT 的 SQL エラー(テーブル削除)→ SELECT COUNT failed 分岐
|
||||||
|
sysin:
|
||||||
|
period: "202607"
|
||||||
|
include_invalid_period: true
|
||||||
|
modes: ["NORMAL"]
|
||||||
|
final_mode: "NORMAL"
|
||||||
|
inject_duplicate_pk: false
|
||||||
|
drop_tables: [MONTHLY_ABSENCE]
|
||||||
|
|||||||
@@ -49,6 +49,30 @@ db_tables:
|
|||||||
- name: UPDATED_AT
|
- name: UPDATED_AT
|
||||||
type: TIMESTAMP
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
runs:
|
||||||
|
- id: FULL
|
||||||
|
command_line: "YEARMONTH=202607,MODE=FULL"
|
||||||
|
seed_extra_rows:
|
||||||
|
DAILY_RECORDS: 100
|
||||||
|
- id: SHORT
|
||||||
|
command_line: "YEARMONTH=202607,MODE=SHORT"
|
||||||
|
|
||||||
|
# Fix A: PARM 组合 + 空结果集场景
|
||||||
|
- id: missing_mode
|
||||||
|
# WS-COMMA-CNT < 1 → 'WARNING: Missing MODE'(MODE 默认 FULL)
|
||||||
|
command_line: "YEARMONTH=202607"
|
||||||
|
- id: reordered_parm
|
||||||
|
# KEY1=MODE(VALUE1→WS-MODE)+ KEY2=YEARMONTH(VALUE2→WS-YEARMONTH)
|
||||||
|
command_line: "MODE=FULL,YEARMONTH=202607"
|
||||||
|
- id: missing_ym
|
||||||
|
# YEARMONTH 未指定 → SPACE → ABEND(SUB03END)
|
||||||
|
command_line: "MODE=FULL"
|
||||||
|
- id: empty_daily
|
||||||
|
# 首 FETCH EOF:删除 DAILY_RECORDS 表 → 光标 OPEN/FETCH 失败 → SQLCODE≠0 → WS-DAILY-EOF
|
||||||
|
# (delete_all_rows 空表时 gixsql 首次 FETCH 返回 SQLCODE=0,无法触发 EOF)
|
||||||
|
command_line: "YEARMONTH=202607,MODE=FULL"
|
||||||
|
drop_tables: [DAILY_RECORDS]
|
||||||
|
|
||||||
subprograms:
|
subprograms:
|
||||||
- SUB02MSG
|
- SUB02MSG
|
||||||
- SUB03END
|
- SUB03END
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
program_id: KYU02REG
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: SALARY.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: EMP_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: EMP_NAME
|
||||||
|
type: VARCHAR(40)
|
||||||
|
- name: DEPT_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: REGION_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: CATEGORY_CODE
|
||||||
|
type: CHAR(3)
|
||||||
|
- name: BASE_SALARY
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: HOURLY_RATE
|
||||||
|
type: DECIMAL(7,0)
|
||||||
|
- name: DEPENDENT_COUNT
|
||||||
|
type: SMALLINT
|
||||||
|
- name: STATUS
|
||||||
|
type: CHAR(1)
|
||||||
|
- name: UPDATED_AT
|
||||||
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB03END
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
program_id: KYU04CAL
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: SALARY.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: EMP_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: EMP_NAME
|
||||||
|
type: VARCHAR(40)
|
||||||
|
- name: DEPT_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: REGION_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: CATEGORY_CODE
|
||||||
|
type: CHAR(3)
|
||||||
|
- name: BASE_SALARY
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: HOURLY_RATE
|
||||||
|
type: DECIMAL(7,0)
|
||||||
|
- name: DEPENDENT_COUNT
|
||||||
|
type: SMALLINT
|
||||||
|
- name: STATUS
|
||||||
|
type: CHAR(1)
|
||||||
|
- name: UPDATED_AT
|
||||||
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
- name: OVT_MONTHLY
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: YEAR_MONTH
|
||||||
|
type: CHAR(6)
|
||||||
|
primary_key: true
|
||||||
|
- name: OVT_TYPE
|
||||||
|
type: CHAR(1)
|
||||||
|
- name: OVT_HOURS
|
||||||
|
type: DECIMAL(4,1)
|
||||||
|
- name: OVT_COUNT
|
||||||
|
type: DECIMAL(9)
|
||||||
|
- name: UPDATED_AT
|
||||||
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB03END
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
program_id: KYU05DED
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: SALARY.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: TAX_TABLE
|
||||||
|
columns:
|
||||||
|
- name: TAX_FROM
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
primary_key: true
|
||||||
|
- name: TAX_TO
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
primary_key: true
|
||||||
|
- name: TAX_RATE
|
||||||
|
type: DECIMAL(5,4)
|
||||||
|
- name: DEDUCTION
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
|
||||||
|
- name: INSURANCE_TABLE
|
||||||
|
columns:
|
||||||
|
- name: INS_INCOME_FROM
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
primary_key: true
|
||||||
|
- name: INS_INCOME_TO
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
primary_key: true
|
||||||
|
- name: INS_HEALTH_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: INS_PENSION_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: UPDATED_AT
|
||||||
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB03END
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
program_id: KYU06UPD
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: SALARY.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: SALARY_RESULTS
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: YEAR_MONTH
|
||||||
|
type: CHAR(6)
|
||||||
|
primary_key: true
|
||||||
|
- name: GROSS_PAYMENT
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: INCOME_TAX
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: INSURANCE
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: RESIDENT_TAX
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: NET_PAYMENT
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: UPDATED_AT
|
||||||
|
type: TIMESTAMP
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB03END
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
program_id: SHA02MNC
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: INSURANCEDB.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: INSURANCE_RATES
|
||||||
|
columns:
|
||||||
|
- name: GRADE_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
primary_key: true
|
||||||
|
- name: MONTHLY_FROM
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: MONTHLY_TO
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: HEALTH_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: PENSION_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: EFFECTIVE_FROM
|
||||||
|
type: CHAR(6)
|
||||||
|
- name: EFFECTIVE_TO
|
||||||
|
type: CHAR(6)
|
||||||
|
|
||||||
|
- name: EMP_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: EMP_NAME
|
||||||
|
type: VARCHAR(40)
|
||||||
|
- name: DEPT_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: BASE_SALARY
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
|
||||||
|
runs:
|
||||||
|
- id: normal
|
||||||
|
- id: emp_open_fail
|
||||||
|
drop_tables: [EMP_MASTER]
|
||||||
|
inject_duplicate_pk: false
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
|
- SUB04CHK
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
program_id: SHA03MNP
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: INSURANCEDB.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: INSURANCE_RATES
|
||||||
|
columns:
|
||||||
|
- name: GRADE_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
primary_key: true
|
||||||
|
- name: MONTHLY_FROM
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: MONTHLY_TO
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
- name: HEALTH_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: PENSION_RATE
|
||||||
|
type: DECIMAL(7,6)
|
||||||
|
- name: EFFECTIVE_FROM
|
||||||
|
type: CHAR(6)
|
||||||
|
- name: EFFECTIVE_TO
|
||||||
|
type: CHAR(6)
|
||||||
|
|
||||||
|
runs:
|
||||||
|
- id: normal
|
||||||
|
- id: rates_open_fail
|
||||||
|
drop_tables: [INSURANCE_RATES]
|
||||||
|
inject_duplicate_pk: false
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
program_id: SHA04TWO
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: INSURANCEDB.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: INSURED_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: INSURED_NO
|
||||||
|
type: CHAR(10)
|
||||||
|
primary_key: true
|
||||||
|
- name: STATUS
|
||||||
|
type: CHAR(1)
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
program_id: SHA06TWM
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: INSURANCEDB.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: INSURANCE_RATES
|
||||||
|
columns:
|
||||||
|
- name: GRADE_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
primary_key: true
|
||||||
|
- name: HEALTH_RATE
|
||||||
|
type: DECIMAL(7,0)
|
||||||
|
- name: PENSION_RATE
|
||||||
|
type: DECIMAL(7,0)
|
||||||
|
|
||||||
|
- name: EMP_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: BIRTH_DATE
|
||||||
|
type: CHAR(8)
|
||||||
|
- name: DEPENDENT_COUNT
|
||||||
|
type: SMALLINT
|
||||||
|
- name: REGION_CODE
|
||||||
|
type: CHAR(2)
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
program_id: SHA07KBR
|
||||||
|
db_type: SQLite
|
||||||
|
db_name: INSURANCEDB.db
|
||||||
|
|
||||||
|
db_tables:
|
||||||
|
- name: QUALIFICATION_CHANGES
|
||||||
|
columns:
|
||||||
|
- name: CHG_ID
|
||||||
|
type: DECIMAL(9,0)
|
||||||
|
primary_key: true
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
- name: CHG_DATE
|
||||||
|
type: CHAR(8)
|
||||||
|
- name: CHG_TYPE
|
||||||
|
type: CHAR(2)
|
||||||
|
- name: INSURER_CODE
|
||||||
|
type: CHAR(4)
|
||||||
|
- name: PREV_INSURER
|
||||||
|
type: CHAR(4)
|
||||||
|
- name: REASON
|
||||||
|
type: VARCHAR(100)
|
||||||
|
|
||||||
|
- name: EMP_MASTER
|
||||||
|
columns:
|
||||||
|
- name: EMP_ID
|
||||||
|
type: CHAR(8)
|
||||||
|
primary_key: true
|
||||||
|
- name: EMP_NAME
|
||||||
|
type: VARCHAR(40)
|
||||||
|
|
||||||
|
subprograms:
|
||||||
|
- SUB01DAT
|
||||||
|
- SUB02MSG
|
||||||
+653
-74
@@ -34,6 +34,33 @@ from runners.gixsql_runner import GixsqlCobolRunner, GixsqlTableData
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_birth_date(age: int, as_of: str = '20260802') -> str:
|
||||||
|
"""年龄 → 出生日期 YYYYMMDD(通用)。as_of 为运行时运用日。"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
base = datetime.strptime(as_of, '%Y%m%d')
|
||||||
|
d = base - timedelta(days=max(age, 0) * 365)
|
||||||
|
return d.strftime('%Y%m%d')
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_run_dirs_gcov(gcov_dir: str | Path, program: str,
|
||||||
|
gcov_func=run_gcov) -> dict[int, int]:
|
||||||
|
"""Merge gcov line counts for ONE program across multi-run scenario dirs.
|
||||||
|
|
||||||
|
Returns {line: max_count}. Subprogram gcov MUST be collected separately
|
||||||
|
(per subprogram name), never merged into the main program's dict: both use
|
||||||
|
plain integer line numbers, so SUB*.cbl line 167 would collide with and
|
||||||
|
overwrite the main program's line 167 (e.g. SUB04CHK 167=0 wiping the
|
||||||
|
main loop's 167=25). See _sub_gcov_data.
|
||||||
|
"""
|
||||||
|
merged: dict[int, int] = {}
|
||||||
|
for sd in sorted(Path(gcov_dir).glob("run_*")):
|
||||||
|
data = gcov_func(program, str(sd))
|
||||||
|
if data:
|
||||||
|
for line, cnt in data.items():
|
||||||
|
merged[line] = max(merged.get(line, 0), cnt)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DbPipelineResult:
|
class DbPipelineResult:
|
||||||
"""DB 管线単体実行結果"""
|
"""DB 管线単体実行結果"""
|
||||||
@@ -86,6 +113,7 @@ class GixsqlOrchestrator:
|
|||||||
self.java_input_path: Optional[Path] = None
|
self.java_input_path: Optional[Path] = None
|
||||||
self._current_db_path: Optional[Path] = None # scenario-specific DB path
|
self._current_db_path: Optional[Path] = None # scenario-specific DB path
|
||||||
self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data
|
self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data
|
||||||
|
self._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main)
|
||||||
self.java_output_path: Optional[Path] = None
|
self.java_output_path: Optional[Path] = None
|
||||||
self.generated_records: list[dict] = []
|
self.generated_records: list[dict] = []
|
||||||
self.generated_structure: dict | None = None
|
self.generated_structure: dict | None = None
|
||||||
@@ -148,6 +176,20 @@ class GixsqlOrchestrator:
|
|||||||
copybook_dirs=[ascii_dir])
|
copybook_dirs=[ascii_dir])
|
||||||
self.pp_path = Path(pp)
|
self.pp_path = Path(pp)
|
||||||
|
|
||||||
|
# Patch gixpp's broken CONNECT string
|
||||||
|
# gixpp converts CONNECT TO 'data/kin.db' -> 'sqlite://localhost/kin'
|
||||||
|
# Fix: use absolute path that gixsql runtime can resolve
|
||||||
|
if self.pp_path and self.pp_path.exists():
|
||||||
|
pp_text = self.pp_path.read_text(encoding='utf-8')
|
||||||
|
old_conn = 'sqlite://localhost/kin'
|
||||||
|
new_conn = f'sqlite:///{self.db_path}'
|
||||||
|
if old_conn in pp_text:
|
||||||
|
pp_text = pp_text.replace(old_conn, new_conn)
|
||||||
|
self.pp_path.write_text(pp_text, encoding='utf-8')
|
||||||
|
logger.info(f" Patched CONNECT: {old_conn} -> {new_conn}")
|
||||||
|
else:
|
||||||
|
logger.info(f" CONNECT string not found (already patched?)")
|
||||||
|
|
||||||
exe = self.work_dir / "bin" / f"{self.program_id}.exe"
|
exe = self.work_dir / "bin" / f"{self.program_id}.exe"
|
||||||
extra_srcs = []
|
extra_srcs = []
|
||||||
for sub in self.schema.subprograms:
|
for sub in self.schema.subprograms:
|
||||||
@@ -235,10 +277,27 @@ class GixsqlOrchestrator:
|
|||||||
|
|
||||||
# DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行)
|
# DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行)
|
||||||
self._populate_database(db_path, src_text, recs, scenario=scenario)
|
self._populate_database(db_path, src_text, recs, scenario=scenario)
|
||||||
|
# seed_extra_rows: 大结果集注入(SELECT 型プログラムの表头重出等の分支)
|
||||||
|
if scenario:
|
||||||
|
self._inject_extra_seed_rows(db_path, scenario)
|
||||||
# P5: inject duplicate-PK rows (scenario で制御)
|
# P5: inject duplicate-PK rows (scenario で制御)
|
||||||
if scenario is None or scenario.inject_duplicate_pk:
|
if scenario is None or scenario.inject_duplicate_pk:
|
||||||
self._inject_sql_error_rows(db_path, recs)
|
self._inject_sql_error_rows(db_path, recs)
|
||||||
|
|
||||||
|
# First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12).
|
||||||
|
# Independent of R01LINE (which may not exist for this program's FD layout).
|
||||||
|
if len(recs) > 0:
|
||||||
|
recs[0]['R01EMP-ID'] = ' ' * 8
|
||||||
|
|
||||||
|
# 全ゼロ EMP-ID レコードを SPACE にクレンジング(汎用)。
|
||||||
|
# プログラムの空社員チェック(R01EMP-ID = SPACE / LOW-VALUES)は
|
||||||
|
# '00000000' を捕捉しないため、そのまま INSERT され DAILY_RECORDS の
|
||||||
|
# (EMP_ID, TARGET_DATE) 主キー衝突 → 早期 ABEND を引き起こす。
|
||||||
|
for rec in recs:
|
||||||
|
_eid = str(rec.get('R01EMP-ID', '')).strip()
|
||||||
|
if _eid == '00000000':
|
||||||
|
rec['R01EMP-ID'] = ' ' * 8
|
||||||
|
|
||||||
# Patch R01LINE records with EMP-IDs matching the record's own EMP-ID
|
# Patch R01LINE records with EMP-IDs matching the record's own EMP-ID
|
||||||
for i, rec in enumerate(recs):
|
for i, rec in enumerate(recs):
|
||||||
line = rec.get('R01LINE', '')
|
line = rec.get('R01LINE', '')
|
||||||
@@ -253,10 +312,8 @@ class GixsqlOrchestrator:
|
|||||||
emp_id = rec.get('HV-EMP-ID', '')
|
emp_id = rec.get('HV-EMP-ID', '')
|
||||||
if not emp_id or emp_id == '00000000':
|
if not emp_id or emp_id == '00000000':
|
||||||
emp_id = f"EMP{str(i).zfill(5)}"
|
emp_id = f"EMP{str(i).zfill(5)}"
|
||||||
# First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12)
|
|
||||||
if i == 0:
|
if i == 0:
|
||||||
rec['R01LINE'] = f"{' '*8},{parts[1]}"
|
rec['R01LINE'] = f"{' '*8},{parts[1]}"
|
||||||
rec['R01EMP-ID'] = ' ' * len(emp_id)
|
|
||||||
else:
|
else:
|
||||||
rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}"
|
rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}"
|
||||||
rec['R01EMP-ID'] = emp_id
|
rec['R01EMP-ID'] = emp_id
|
||||||
@@ -286,20 +343,25 @@ class GixsqlOrchestrator:
|
|||||||
src_date = r.get('R01DATE', '')
|
src_date = r.get('R01DATE', '')
|
||||||
break
|
break
|
||||||
dup_date = src_date
|
dup_date = src_date
|
||||||
|
# Track used days to avoid PK conflict with src record's date
|
||||||
|
src_day = dup_date[6:8] if dup_date and len(dup_date) >= 8 else ''
|
||||||
|
used_days = set()
|
||||||
|
if src_day:
|
||||||
|
used_days.add(src_day)
|
||||||
for j in range(max(1, len(recs)-3), len(recs)):
|
for j in range(max(1, len(recs)-3), len(recs)):
|
||||||
rec = recs[j]
|
rec = recs[j]
|
||||||
if not dup_eid:
|
if not dup_eid:
|
||||||
continue
|
continue
|
||||||
rec['R01EMP-ID'] = dup_eid
|
rec['R01EMP-ID'] = dup_eid
|
||||||
# FIXED format: keep same YEAR_MONTH but different day
|
# FIXED format: keep same YEAR_MONTH but uniquely different day
|
||||||
# to avoid PK conflict in DAILY_RECORDS INSERT.
|
# to avoid PK conflict in DAILY_RECORDS INSERT (EMP_ID + TARGET_DATE).
|
||||||
if dup_date and len(dup_date) >= 6:
|
if dup_date and len(dup_date) >= 6:
|
||||||
dup_ym = dup_date[:6]
|
dup_ym = dup_date[:6]
|
||||||
orig_date = rec.get('R01DATE', '')
|
orig_date = rec.get('R01DATE', '')
|
||||||
if orig_date and len(orig_date) >= 8:
|
orig_day = orig_date[6:8] if orig_date and len(orig_date) >= 8 else ''
|
||||||
rec['R01DATE'] = dup_ym + orig_date[6:8]
|
day = orig_day if (orig_day and orig_day not in used_days) else f"{len(used_days)+1:02d}"
|
||||||
else:
|
used_days.add(day)
|
||||||
rec['R01DATE'] = dup_ym + '01'
|
rec['R01DATE'] = dup_ym + day
|
||||||
# LINE SEQUENTIAL format: patch R01LINE
|
# LINE SEQUENTIAL format: patch R01LINE
|
||||||
line = rec.get('R01LINE', '')
|
line = rec.get('R01LINE', '')
|
||||||
if line:
|
if line:
|
||||||
@@ -307,6 +369,9 @@ class GixsqlOrchestrator:
|
|||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}"
|
rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}"
|
||||||
|
|
||||||
|
# 聚合边界数据(overflow / agg table full),通用注入,作用于共享 records
|
||||||
|
self._inject_aggregation_boundaries(recs)
|
||||||
|
|
||||||
# ── Coverage-driven data modifications (per-scenario) ──
|
# ── Coverage-driven data modifications (per-scenario) ──
|
||||||
# Normal scenario or legacy single-run: no modifications needed.
|
# Normal scenario or legacy single-run: no modifications needed.
|
||||||
# Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold.
|
# Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold.
|
||||||
@@ -367,6 +432,9 @@ class GixsqlOrchestrator:
|
|||||||
r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ'
|
r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ'
|
||||||
logger.info(f" Coverage #14T: set last R02 APPL-ID='ZZZZZZZZ' for orphan cancel")
|
logger.info(f" Coverage #14T: set last R02 APPL-ID='ZZZZZZZZ' for orphan cancel")
|
||||||
|
|
||||||
|
# 全レコードの(EMP_ID, DATE)重複チェック(PK衝突→ABEND防止)
|
||||||
|
self._deduplicate_r01_pk(recs)
|
||||||
|
|
||||||
# 出力先ディレクトリ(シナリオ毎に分離)
|
# 出力先ディレクトリ(シナリオ毎に分離)
|
||||||
run_label = f"run_{scenario.id}" if scenario else ""
|
run_label = f"run_{scenario.id}" if scenario else ""
|
||||||
output_root = self.work_dir / run_label if scenario else self.work_dir
|
output_root = self.work_dir / run_label if scenario else self.work_dir
|
||||||
@@ -384,6 +452,7 @@ class GixsqlOrchestrator:
|
|||||||
"period": scenario.sysin.period,
|
"period": scenario.sysin.period,
|
||||||
"include_invalid_period": scenario.sysin.include_invalid_period,
|
"include_invalid_period": scenario.sysin.include_invalid_period,
|
||||||
"modes": scenario.sysin.modes,
|
"modes": scenario.sysin.modes,
|
||||||
|
"final_mode": scenario.sysin.final_mode,
|
||||||
}
|
}
|
||||||
sysin_path = write_sysin_file(recs, src_text, input_dir,
|
sysin_path = write_sysin_file(recs, src_text, input_dir,
|
||||||
copybook_dirs=[str(d) for d in self.copybook_dirs],
|
copybook_dirs=[str(d) for d in self.copybook_dirs],
|
||||||
@@ -406,7 +475,7 @@ class GixsqlOrchestrator:
|
|||||||
data_fields = parse_data_division(data_div) if data_div else []
|
data_fields = parse_data_division(data_div) if data_div else []
|
||||||
fdict = []
|
fdict = []
|
||||||
for f in data_fields:
|
for f in data_fields:
|
||||||
fdict.append({
|
_entry = {
|
||||||
'name': f.name, 'level': f.level, 'pic': f.pic,
|
'name': f.name, 'level': f.level, 'pic': f.pic,
|
||||||
'pic_info': {
|
'pic_info': {
|
||||||
'type': f.pic_info.type if f.pic_info else 'unknown',
|
'type': f.pic_info.type if f.pic_info else 'unknown',
|
||||||
@@ -419,7 +488,11 @@ class GixsqlOrchestrator:
|
|||||||
'occurs_depending': f.occurs_depending,
|
'occurs_depending': f.occurs_depending,
|
||||||
'value': f.value, 'values': f.values,
|
'value': f.value, 'values': f.values,
|
||||||
'redefines': f.redefines, 'usage': f.usage,
|
'redefines': f.redefines, 'usage': f.usage,
|
||||||
})
|
}
|
||||||
|
if f.is_88:
|
||||||
|
_entry['is_88'] = True
|
||||||
|
_entry['parent'] = f.parent
|
||||||
|
fdict.append(_entry)
|
||||||
fdict = expand_occurs(fdict)
|
fdict = expand_occurs(fdict)
|
||||||
proc_div = extract_procedure_division(pp)
|
proc_div = extract_procedure_division(pp)
|
||||||
branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict)
|
branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict)
|
||||||
@@ -460,12 +533,14 @@ class GixsqlOrchestrator:
|
|||||||
|
|
||||||
# DB input for JSON
|
# DB input for JSON
|
||||||
data_div2, declared_columns = strip_exec_sql_from_data_div(data_div)
|
data_div2, declared_columns = strip_exec_sql_from_data_div(data_div)
|
||||||
|
declared_columns = self._merge_schema_columns(declared_columns)
|
||||||
sql_meta = collect_sql_meta(assignments, declared_columns)
|
sql_meta = collect_sql_meta(assignments, declared_columns)
|
||||||
db_input = None
|
db_input = None
|
||||||
if sql_meta:
|
if sql_meta:
|
||||||
db_input = build_db_input(
|
db_input = build_db_input(
|
||||||
branch_paths, fdict, assignments,
|
branch_paths, fdict, assignments,
|
||||||
sql_meta, declared_columns, records=recs)
|
sql_meta, declared_columns, records=recs,
|
||||||
|
insert_pk=self._insert_pk_map())
|
||||||
|
|
||||||
# Write main JSON(シナリオ毎に分離)
|
# Write main JSON(シナリオ毎に分離)
|
||||||
json_outdir = output_root / "main" / "json"
|
json_outdir = output_root / "main" / "json"
|
||||||
@@ -551,14 +626,19 @@ class GixsqlOrchestrator:
|
|||||||
self.db_path.unlink()
|
self.db_path.unlink()
|
||||||
shutil.copy2(str(db_path), str(self.db_path))
|
shutil.copy2(str(db_path), str(self.db_path))
|
||||||
db_path = self.db_path
|
db_path = self.db_path
|
||||||
# CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー
|
# CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー(単輪/多輪共通)
|
||||||
if scenario is not None:
|
cwd_data = cwd / "data"
|
||||||
cwd_data = cwd / "data"
|
cwd_data.mkdir(parents=True, exist_ok=True)
|
||||||
cwd_data.mkdir(parents=True, exist_ok=True)
|
cwd_db = cwd_data / "kin.db"
|
||||||
cwd_db = cwd_data / "kin.db"
|
if cwd_db.exists():
|
||||||
if cwd_db.exists():
|
cwd_db.unlink()
|
||||||
cwd_db.unlink()
|
shutil.copy2(str(db_path), str(cwd_db))
|
||||||
shutil.copy2(str(db_path), str(cwd_db))
|
# gixsql regex requires sqlite://host/path (single segment, no dots).
|
||||||
|
# Copy to CWD/kin (no extension) for sqlite://localhost/kin.
|
||||||
|
cwd_kin = cwd / "kin"
|
||||||
|
if cwd_kin.exists():
|
||||||
|
cwd_kin.unlink()
|
||||||
|
shutil.copy2(str(db_path), str(cwd_kin))
|
||||||
|
|
||||||
# .gcda は CWD(= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
|
# .gcda は CWD(= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
|
||||||
# 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため)
|
# 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため)
|
||||||
@@ -569,15 +649,27 @@ class GixsqlOrchestrator:
|
|||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Create parent directories for all ASSIGN TO files (COBOL needs them to exist)
|
||||||
|
for fname, direction in assign_map.items():
|
||||||
|
if os.sep in fname or '/' in fname:
|
||||||
|
parent = cwd / os.path.dirname(fname)
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Subprogram DLLs
|
# Subprogram DLLs
|
||||||
cobol_bin = Path(self.cobol_src_dir).parent / "bin"
|
cobol_bin = Path(self.cobol_src_dir).parent / "bin"
|
||||||
|
|
||||||
|
# command_line: scenario-level (if set) overrides program-level default
|
||||||
|
cmd_line = self.schema.command_line
|
||||||
|
if scenario and scenario.command_line is not None:
|
||||||
|
cmd_line = scenario.command_line
|
||||||
|
command_args = cmd_line.split() if cmd_line else None
|
||||||
result = self.runner.run(
|
result = self.runner.run(
|
||||||
self.exe_path, cwd,
|
self.exe_path, cwd,
|
||||||
db_path,
|
db_path,
|
||||||
input_dir=None,
|
input_dir=None,
|
||||||
cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None,
|
cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None,
|
||||||
env_overrides=env_overrides,
|
env_overrides=env_overrides,
|
||||||
|
command_args=command_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
log_dir = self.runtime_dir / "logs"
|
log_dir = self.runtime_dir / "logs"
|
||||||
@@ -642,12 +734,7 @@ class GixsqlOrchestrator:
|
|||||||
if not dst.exists():
|
if not dst.exists():
|
||||||
shutil.copy2(str(f), str(dst))
|
shutil.copy2(str(f), str(dst))
|
||||||
|
|
||||||
merged_data: dict[int, int] = {}
|
merged_data = _merge_run_dirs_gcov(gcov_dir, f"{self.program_id}_pp")
|
||||||
for sd in run_dirs:
|
|
||||||
data = run_gcov(f"{self.program_id}_pp", str(sd))
|
|
||||||
if data:
|
|
||||||
for line, count in data.items():
|
|
||||||
merged_data[line] = max(merged_data.get(line, 0), count)
|
|
||||||
|
|
||||||
logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)")
|
logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)")
|
||||||
return merged_data
|
return merged_data
|
||||||
@@ -674,18 +761,16 @@ class GixsqlOrchestrator:
|
|||||||
# 1. Use pre-merged multi-run gcov data if available (skip gcov re-run)
|
# 1. Use pre-merged multi-run gcov data if available (skip gcov re-run)
|
||||||
if self._multi_run_gcov_data is not None:
|
if self._multi_run_gcov_data is not None:
|
||||||
gcov_data = self._multi_run_gcov_data
|
gcov_data = self._multi_run_gcov_data
|
||||||
# Also merge subprogram gcov data from each scenario
|
# Subprogram gcov is kept separate: SUB*.cbl line numbers are
|
||||||
from cobol_testgen.gcov import run_gcov as _run_gcov
|
# plain integers that collide with the main program's (e.g.
|
||||||
|
# SUB04CHK line 167=0 would overwrite main line 167=25 and
|
||||||
|
# wipe real coverage). Stored per-subprogram for reference.
|
||||||
gcov_dir = self.runtime_dir / "gcov"
|
gcov_dir = self.runtime_dir / "gcov"
|
||||||
|
self._sub_gcov_data = {}
|
||||||
for sub in self.schema.subprograms:
|
for sub in self.schema.subprograms:
|
||||||
sub_merged: dict[int, int] = {}
|
sub_merged = _merge_run_dirs_gcov(gcov_dir, sub)
|
||||||
for sd in sorted(gcov_dir.glob("run_*")):
|
|
||||||
sub_data = _run_gcov(sub, str(sd))
|
|
||||||
if sub_data:
|
|
||||||
for line, cnt in sub_data.items():
|
|
||||||
sub_merged[line] = max(sub_merged.get(line, 0), cnt)
|
|
||||||
if sub_merged:
|
if sub_merged:
|
||||||
gcov_data.update(sub_merged)
|
self._sub_gcov_data[sub] = sub_merged
|
||||||
else:
|
else:
|
||||||
# Single-run: collect .gcno/.gcda and run gcov
|
# Single-run: collect .gcno/.gcda and run gcov
|
||||||
gcov_dir = self.runtime_dir / "gcov"
|
gcov_dir = self.runtime_dir / "gcov"
|
||||||
@@ -726,10 +811,12 @@ class GixsqlOrchestrator:
|
|||||||
gcov_data = run_gcov(f"{self.program_id}_pp", str(gcov_dir))
|
gcov_data = run_gcov(f"{self.program_id}_pp", str(gcov_dir))
|
||||||
if not gcov_data:
|
if not gcov_data:
|
||||||
gcov_data = run_gcov(self.program_id, str(gcov_dir))
|
gcov_data = run_gcov(self.program_id, str(gcov_dir))
|
||||||
|
# Subprogram gcov kept separate (line numbers collide with main).
|
||||||
|
self._sub_gcov_data = {}
|
||||||
for sub in self.schema.subprograms:
|
for sub in self.schema.subprograms:
|
||||||
sd = run_gcov(sub, str(gcov_dir))
|
sd = run_gcov(sub, str(gcov_dir))
|
||||||
if sd:
|
if sd:
|
||||||
gcov_data.update(sd)
|
self._sub_gcov_data[sub] = sd
|
||||||
|
|
||||||
# 4. Static branch tree from step2
|
# 4. Static branch tree from step2
|
||||||
st = self.generated_structure
|
st = self.generated_structure
|
||||||
@@ -758,6 +845,7 @@ class GixsqlOrchestrator:
|
|||||||
},
|
},
|
||||||
'section': f.section, 'occurs': f.occurs_count,
|
'section': f.section, 'occurs': f.occurs_count,
|
||||||
'occurs_depending': f.occurs_depending,
|
'occurs_depending': f.occurs_depending,
|
||||||
|
'value': f.value, 'values': f.values,
|
||||||
'redefines': f.redefines, 'usage': f.usage,
|
'redefines': f.redefines, 'usage': f.usage,
|
||||||
}
|
}
|
||||||
if f.is_88:
|
if f.is_88:
|
||||||
@@ -1011,7 +1099,7 @@ class GixsqlOrchestrator:
|
|||||||
# First pass: collect all SELECT/ASSIGN-TO mappings
|
# First pass: collect all SELECT/ASSIGN-TO mappings
|
||||||
select_to_file: dict[str, str] = {}
|
select_to_file: dict[str, str] = {}
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+"?([^"\s.]+)',
|
r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+(?:EXTERNAL\s+)?"?([^"\s.]+)',
|
||||||
src_text, re.IGNORECASE
|
src_text, re.IGNORECASE
|
||||||
):
|
):
|
||||||
sel_name = m.group(1)
|
sel_name = m.group(1)
|
||||||
@@ -1078,6 +1166,58 @@ class GixsqlOrchestrator:
|
|||||||
conn.close()
|
conn.close()
|
||||||
logger.info(f" DB initialized: {db_path}")
|
logger.info(f" DB initialized: {db_path}")
|
||||||
|
|
||||||
|
def _merge_schema_columns(self, declared_columns: dict) -> dict:
|
||||||
|
"""YAML スキーマのカラム型を declared_columns にマージする。
|
||||||
|
EXEC SQL DECLARE TABLE がないプログラムでも正しい型が使われるようにする。"""
|
||||||
|
import re
|
||||||
|
for t in self.schema.db_tables:
|
||||||
|
name = t.name.upper()
|
||||||
|
if name not in declared_columns:
|
||||||
|
declared_columns[name] = []
|
||||||
|
existing = {c['name'].upper() for c in declared_columns[name]}
|
||||||
|
for c in t.columns:
|
||||||
|
if c.name.upper() in existing:
|
||||||
|
continue
|
||||||
|
raw = c.type.upper()
|
||||||
|
if raw.startswith('CHAR('):
|
||||||
|
m = re.search(r'\((\d+)\)', raw)
|
||||||
|
col = {'name': c.name, 'db_type': 'CHAR',
|
||||||
|
'size': int(m.group(1)) if m else 1}
|
||||||
|
elif raw.startswith('VARCHAR('):
|
||||||
|
m = re.search(r'\((\d+)\)', raw)
|
||||||
|
col = {'name': c.name, 'db_type': 'VARCHAR',
|
||||||
|
'size': int(m.group(1)) if m else 50}
|
||||||
|
elif raw in ('INTEGER',):
|
||||||
|
col = {'name': c.name, 'db_type': 'INTEGER'}
|
||||||
|
elif raw in ('SMALLINT',):
|
||||||
|
col = {'name': c.name, 'db_type': 'SMALLINT'}
|
||||||
|
elif raw.startswith('DECIMAL(') or raw.startswith('NUMERIC('):
|
||||||
|
m = re.search(r'\((\d+)\s*,?\s*(\d+)?\)', raw)
|
||||||
|
col = {'name': c.name, 'db_type': 'DECIMAL',
|
||||||
|
'precision': int(m.group(1)) if m else 6,
|
||||||
|
'scale': int(m.group(2)) if m and m.group(2) else 0}
|
||||||
|
elif raw in ('DATE', 'TIMESTAMP'):
|
||||||
|
col = {'name': c.name, 'db_type': 'DATE'}
|
||||||
|
else:
|
||||||
|
col = {'name': c.name, 'db_type': 'CHAR', 'size': 20}
|
||||||
|
declared_columns[name].append(col)
|
||||||
|
return declared_columns
|
||||||
|
|
||||||
|
def _insert_pk_map(self) -> dict[str, list[str]]:
|
||||||
|
"""Map SQL table name → primary-key column names from the YAML schema.
|
||||||
|
|
||||||
|
Used to generate PK-collision pre-seed rows for INSERT statements so the
|
||||||
|
duplicate-key error path (SQLCODE = -803) is reachable at runtime.
|
||||||
|
"""
|
||||||
|
pk_map = {}
|
||||||
|
for t in self.schema.db_tables:
|
||||||
|
cols = [c.name for c in t.columns if c.primary_key]
|
||||||
|
if cols:
|
||||||
|
for name in {t.name, t.name.replace('_', '-'), t.sql_name}:
|
||||||
|
if name:
|
||||||
|
pk_map[name] = cols
|
||||||
|
return pk_map
|
||||||
|
|
||||||
def _populate_database(self, db_path: Path, src_text: str, records: list[dict],
|
def _populate_database(self, db_path: Path, src_text: str, records: list[dict],
|
||||||
scenario: ScenarioDef | None = None):
|
scenario: ScenarioDef | None = None):
|
||||||
"""テストデータから DB 初期行を生成し挿入する。"""
|
"""テストデータから DB 初期行を生成し挿入する。"""
|
||||||
@@ -1094,7 +1234,7 @@ class GixsqlOrchestrator:
|
|||||||
data_fields = parse_data_division(data_div) if data_div else []
|
data_fields = parse_data_division(data_div) if data_div else []
|
||||||
fields_dict = []
|
fields_dict = []
|
||||||
for f in data_fields:
|
for f in data_fields:
|
||||||
fields_dict.append({
|
_entry = {
|
||||||
'name': f.name, 'level': f.level, 'pic': f.pic,
|
'name': f.name, 'level': f.level, 'pic': f.pic,
|
||||||
'pic_info': {
|
'pic_info': {
|
||||||
'type': f.pic_info.type if f.pic_info else 'unknown',
|
'type': f.pic_info.type if f.pic_info else 'unknown',
|
||||||
@@ -1107,7 +1247,11 @@ class GixsqlOrchestrator:
|
|||||||
'occurs_depending': f.occurs_depending,
|
'occurs_depending': f.occurs_depending,
|
||||||
'value': f.value, 'values': f.values,
|
'value': f.value, 'values': f.values,
|
||||||
'redefines': f.redefines, 'usage': f.usage,
|
'redefines': f.redefines, 'usage': f.usage,
|
||||||
})
|
}
|
||||||
|
if f.is_88:
|
||||||
|
_entry['is_88'] = True
|
||||||
|
_entry['parent'] = f.parent
|
||||||
|
fields_dict.append(_entry)
|
||||||
fields_dict = expand_occurs(fields_dict)
|
fields_dict = expand_occurs(fields_dict)
|
||||||
|
|
||||||
proc_div = extract_procedure_division(preprocessed)
|
proc_div = extract_procedure_division(preprocessed)
|
||||||
@@ -1127,10 +1271,12 @@ class GixsqlOrchestrator:
|
|||||||
logger.info(" No SQL metadata found, skipping DB population")
|
logger.info(" No SQL metadata found, skipping DB population")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
declared_columns = self._merge_schema_columns(declared_columns)
|
||||||
db_input = build_db_input(
|
db_input = build_db_input(
|
||||||
branch_paths, fields_dict, assignments,
|
branch_paths, fields_dict, assignments,
|
||||||
sql_meta, declared_columns,
|
sql_meta, declared_columns,
|
||||||
records=records,
|
records=records,
|
||||||
|
insert_pk=self._insert_pk_map(),
|
||||||
)
|
)
|
||||||
if not db_input:
|
if not db_input:
|
||||||
logger.info(" No DB input rows generated")
|
logger.info(" No DB input rows generated")
|
||||||
@@ -1165,6 +1311,50 @@ class GixsqlOrchestrator:
|
|||||||
if i < len(holiday_overrides):
|
if i < len(holiday_overrides):
|
||||||
row['HOLIDAY_DATE'] = holiday_overrides[i]
|
row['HOLIDAY_DATE'] = holiday_overrides[i]
|
||||||
|
|
||||||
|
# -- DAILY_RECORDS date enrichment: replace counter dates with valid YYYYMMDD --
|
||||||
|
if 'DAILY_RECORDS' in db_input:
|
||||||
|
dr_rows = db_input['DAILY_RECORDS']
|
||||||
|
for i, row in enumerate(dr_rows):
|
||||||
|
day = (i % 31) + 1
|
||||||
|
row['TARGET_DATE'] = f'202607{day:02d}'
|
||||||
|
|
||||||
|
# -- MONTHLY_ABSENCE YEAR_MONTH enrichment: match command-line YEARMONTH --
|
||||||
|
if 'MONTHLY_ABSENCE' in db_input:
|
||||||
|
ym = '202607'
|
||||||
|
for row in db_input['MONTHLY_ABSENCE']:
|
||||||
|
row['YEAR_MONTH'] = ym
|
||||||
|
|
||||||
|
# -- INSURANCE-RATES ↔ EMP-MASTER SEARCH/EVALUATE coordination --
|
||||||
|
# Programs load all rate rows effective for the runtime YEAR-MONTH
|
||||||
|
# (WHERE EFFECTIVE-FROM <= :ym AND EFFECTIVE-TO >= :ym), SEARCH the
|
||||||
|
# internal WRK-RATE-ENTRY table against each employee's BASE-SALARY,
|
||||||
|
# then EVALUATE DEPT-CODE. For the SEARCH to find a match (→ EVALUATE),
|
||||||
|
# some EMP BASE_SALARY must fall inside a loaded rate's
|
||||||
|
# MONTHLY_FROM..TO, and DEPT-CODE must span the EVALUATE ranges.
|
||||||
|
# Gated on the EFFECTIVE window pattern (SHA02MNC-style) so programs
|
||||||
|
# querying rates by other keys (e.g. SHA06TWM GRADE-CODE lookup) are
|
||||||
|
# untouched. Table-name driven, not program-ID hardcoded.
|
||||||
|
if ('INSURANCE-RATES' in db_input and 'EMP-MASTER' in db_input
|
||||||
|
and any('EFFECTIVE-FROM' in str(m.get('where', '')).upper()
|
||||||
|
for m in sql_meta if m.get('table') == 'INSURANCE-RATES')):
|
||||||
|
rate_rows = db_input.get('INSURANCE-RATES', [])
|
||||||
|
emp_rows = db_input.get('EMP-MASTER', [])
|
||||||
|
if rate_rows and emp_rows:
|
||||||
|
# First loaded rate (lowest GRADE_CODE, ORDER BY GRADE_CODE)
|
||||||
|
# gets a MONTHLY band covering the target salaries. Other
|
||||||
|
# employees' salaries stay OUTSIDE the band → SEARCH AT END
|
||||||
|
# (W02 error log) so both SEARCH branches are runtime-covered.
|
||||||
|
band_lo = 40000
|
||||||
|
band_hi = 40500
|
||||||
|
rate_rows[0]['MONTHLY_FROM'] = str(band_lo)
|
||||||
|
rate_rows[0]['MONTHLY_TO'] = str(band_hi)
|
||||||
|
# EMP: first rows inside the band, DEPT-CODE spanning the
|
||||||
|
# EVALUATE ranges (1-10 / 11-20 / 21-30 / OTHER).
|
||||||
|
dept_vals = ['1', '11', '21', '99']
|
||||||
|
for i, row in enumerate(emp_rows[:4]):
|
||||||
|
row['DEPT_CODE'] = dept_vals[i]
|
||||||
|
row['BASE_SALARY'] = str(band_lo + i * 100)
|
||||||
|
|
||||||
# -- Per-scenario row overrides (from YAML runs[].row_overrides) --
|
# -- Per-scenario row overrides (from YAML runs[].row_overrides) --
|
||||||
if scenario and scenario.row_overrides:
|
if scenario and scenario.row_overrides:
|
||||||
for table_name, overrides in scenario.row_overrides.items():
|
for table_name, overrides in scenario.row_overrides.items():
|
||||||
@@ -1173,12 +1363,26 @@ class GixsqlOrchestrator:
|
|||||||
for col, val in overrides.items():
|
for col, val in overrides.items():
|
||||||
row[col.upper()] = val
|
row[col.upper()] = val
|
||||||
|
|
||||||
|
# -- DB 属性区间对齐(通用)--
|
||||||
|
# 补全 DB 种子键(INSURANCE-RATES 的 GRADE / EMP-MASTER 的 EMP-ID),
|
||||||
|
# 并将部分 EMP-MASTER 属性(BIRTH-DATE / DEPENDENT-COUNT / REGION-CODE)
|
||||||
|
# 对齐到 flat R02 RULE-TBL 的 AGE/DEPENDENTS/REGION 区间,使
|
||||||
|
# 第 2 段階ルールマッチング(2020RULESCOL)命中経路到達可能。
|
||||||
|
self._coordinate_db_rule_matching(db_input, records, fields_dict)
|
||||||
|
# DB 种子值数字化:DB SELECT 种子列若对应 COBOL 输出 FD 的 PIC 9
|
||||||
|
# (数字)字段,但值形如 'G0000001'(字母+数字),剥离字母转纯数字,
|
||||||
|
# 使 MOVE 到 PIC 9 输出合法(W01/W02 EMP-ID/CHG-DATE 正确显示)。
|
||||||
|
self._coordinate_seed_numeric_types(db_input, fields_dict)
|
||||||
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
for table_name, rows in db_input.items():
|
for table_name, rows in db_input.items():
|
||||||
if not rows:
|
if not rows:
|
||||||
logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)")
|
logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Normalize DB2 hyphenated identifiers -> underscores (schema uses underscores)
|
||||||
|
db_table = table_name.replace('-', '_')
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}")
|
logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}")
|
||||||
|
|
||||||
@@ -1186,7 +1390,7 @@ class GixsqlOrchestrator:
|
|||||||
col_types = {}
|
col_types = {}
|
||||||
try:
|
try:
|
||||||
pragma_cols = conn.execute(
|
pragma_cols = conn.execute(
|
||||||
f"PRAGMA table_info([{table_name}])"
|
f"PRAGMA table_info([{db_table}])"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
valid_cols = {r[1].upper() for r in pragma_cols}
|
valid_cols = {r[1].upper() for r in pragma_cols}
|
||||||
col_types = {r[1].upper(): r[2].upper() for r in pragma_cols}
|
col_types = {r[1].upper(): r[2].upper() for r in pragma_cols}
|
||||||
@@ -1197,8 +1401,9 @@ class GixsqlOrchestrator:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
new_row = {}
|
new_row = {}
|
||||||
for k, v in row.items():
|
for k, v in row.items():
|
||||||
if k.upper() in valid_cols:
|
k_norm = k.replace('-', '_')
|
||||||
new_row[k] = v
|
if k_norm.upper() in valid_cols:
|
||||||
|
new_row[k_norm] = v
|
||||||
if new_row:
|
if new_row:
|
||||||
remapped_rows.append(new_row)
|
remapped_rows.append(new_row)
|
||||||
rows = remapped_rows
|
rows = remapped_rows
|
||||||
@@ -1225,20 +1430,219 @@ class GixsqlOrchestrator:
|
|||||||
col_names = list(rows[0].keys())
|
col_names = list(rows[0].keys())
|
||||||
placeholders = ", ".join("?" for _ in col_names)
|
placeholders = ", ".join("?" for _ in col_names)
|
||||||
quoted_cols = ", ".join(f"[{c}]" for c in col_names)
|
quoted_cols = ", ".join(f"[{c}]" for c in col_names)
|
||||||
sql = f"INSERT OR IGNORE INTO [{table_name}] ({quoted_cols}) VALUES ({placeholders})"
|
sql = f"INSERT OR IGNORE INTO [{db_table}] ({quoted_cols}) VALUES ({placeholders})"
|
||||||
conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows])
|
conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows])
|
||||||
logger.info(f" Table {table_name}: {len(rows)} initial rows inserted")
|
logger.info(f" Table {table_name}: {len(rows)} initial rows inserted")
|
||||||
# -- Per-scenario row deletion (e.g. empty cursor scenario) --
|
# -- Per-scenario row deletion (e.g. empty cursor scenario) --
|
||||||
if scenario and scenario.delete_all_rows:
|
if scenario and scenario.delete_all_rows:
|
||||||
for table_name in db_input.keys():
|
for table_name in db_input.keys():
|
||||||
conn.execute(f"DELETE FROM [{table_name}]")
|
conn.execute(f"DELETE FROM [{table_name.replace('-', '_')}]")
|
||||||
logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})")
|
logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})")
|
||||||
|
|
||||||
|
# -- Per-scenario table drop (e.g. OPEN CURSOR failure scenario) --
|
||||||
|
# Drops the table so a subsequent SQL OPEN/query fails (SQLCODE != 0),
|
||||||
|
# covering the SQL-error branch. Generic: any program may declare
|
||||||
|
# drop_tables to exercise its table-not-found error paths.
|
||||||
|
if scenario and scenario.drop_tables:
|
||||||
|
for table_name in scenario.drop_tables:
|
||||||
|
conn.execute(f"DROP TABLE IF EXISTS [{table_name.replace('-', '_')}]")
|
||||||
|
logger.info(f" Table {table_name}: dropped (scenario={scenario.id})")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info(f" DB populated: {db_path}")
|
logger.info(f" DB populated: {db_path}")
|
||||||
|
|
||||||
|
def _coordinate_db_rule_matching(self, db_input, records, data_fields):
|
||||||
|
"""DB 属性区间对齐(通用,无程序硬编码)。
|
||||||
|
|
||||||
|
适用:DB 从 EMP-MASTER 取 属性(BIRTH-DATE / DEPENDENT-COUNT /
|
||||||
|
REGION-CODE),再与 flat R02 RULE-TBL 的 AGE-FROM/TO、
|
||||||
|
DEPENDENTS-FROM/TO、REGION-CODE 区间做 M:N 照合するプログラム
|
||||||
|
(SHA06TWM 等)。生成データでは DB 属性と R02 区间が独立合成され
|
||||||
|
数量级/値域がずれ、照合命中が発生しない。
|
||||||
|
|
||||||
|
本関数:
|
||||||
|
1) 補全 INSURANCE-RATES 种子鍵:R01 の GRADE-CODE と DB GRADE_CODE
|
||||||
|
の差を埋める(DB-ERR → 主経路)。
|
||||||
|
2) 補全 EMP-MASTER 种子:R01 の EMP-ID と DB EMP_ID の差を埋める。
|
||||||
|
3) 属性区间对齐:DB EMP-MASTER の一部行の属性を R02 RULE-TBL の
|
||||||
|
区间内値に設定し(AGE≈70 / DEP≈85 / REGION=G1 等)、照合命中を
|
||||||
|
発生させる。他行は区间外を維持し no-data/不照合分支を保持。
|
||||||
|
|
||||||
|
検出はテーブル名 + R02 区间フィールド名パターン(AGE-FROM /
|
||||||
|
DEPENDENTS-FROM / REGION-CODE)で行う。プログラム名ハードコードなし。
|
||||||
|
"""
|
||||||
|
if not db_input or not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 从 records 提取 R02 RULE-TBL 区间(AGE/DEPENDENTS/REGION + 调整率)
|
||||||
|
rule_age_from = rule_age_to = None
|
||||||
|
rule_dep_from = rule_dep_to = None
|
||||||
|
rule_region = None
|
||||||
|
for rec in records:
|
||||||
|
v_af = str(rec.get('R02AGE-FROM', '')).strip()
|
||||||
|
v_at = str(rec.get('R02AGE-TO', '')).strip()
|
||||||
|
v_df = str(rec.get('R02DEPENDENTS-FROM', '')).strip()
|
||||||
|
v_dt = str(rec.get('R02DEPENDENTS-TO', '')).strip()
|
||||||
|
v_rg = str(rec.get('R02REGION-CODE', '')).strip()
|
||||||
|
if v_af.isdigit() and v_at.isdigit() and v_df.isdigit() and v_dt.isdigit() and v_rg:
|
||||||
|
rule_age_from, rule_age_to = int(v_af), int(v_at)
|
||||||
|
rule_dep_from, rule_dep_to = int(v_df), int(v_dt)
|
||||||
|
rule_region = v_rg
|
||||||
|
break
|
||||||
|
# R02 区间模式未检测到 → 不做对齐(避免误伤其他程序)
|
||||||
|
if rule_age_from is None or not rule_region:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) 属性区间对齐:将 EMP-MASTER 已有行的属性设为 RULE 区间内值。
|
||||||
|
# 仅对齐部分行(保留反例 → no-data/不照合分支维持),不补全 DB 键
|
||||||
|
# (缺失 GRADE/EMP-ID 记录继续走 DB-ERR → SQLCODE≠0 分支覆盖)。
|
||||||
|
# AGE≈(from+to)/2 → BIRTH-DATE ≈ 運営日付(20260802) - age*365
|
||||||
|
# DEPENDENTS≈(from+to)/2, REGION = RULE-TBL REGION
|
||||||
|
if 'EMP-MASTER' in db_input:
|
||||||
|
emp_rows = db_input['EMP-MASTER']
|
||||||
|
mid_age = (rule_age_from + rule_age_to) // 2
|
||||||
|
mid_dep = (rule_dep_from + rule_dep_to) // 2
|
||||||
|
birth_date = _calc_birth_date(mid_age)
|
||||||
|
aligned = 0
|
||||||
|
for row in emp_rows:
|
||||||
|
# 仅对齐部分行(保留反例)
|
||||||
|
if aligned >= 4:
|
||||||
|
break
|
||||||
|
if 'EMP_ID' not in row or 'BIRTH_DATE' not in row:
|
||||||
|
continue
|
||||||
|
row['BIRTH_DATE'] = birth_date
|
||||||
|
row['DEPENDENT_COUNT'] = str(mid_dep)
|
||||||
|
row['REGION_CODE'] = rule_region
|
||||||
|
aligned += 1
|
||||||
|
if aligned:
|
||||||
|
logger.info(
|
||||||
|
f" DB 属性区间对齐: {aligned} 条 EMP-MASTER 属性→"
|
||||||
|
f"BIRTH={birth_date}(AGE~{mid_age}) DEP={mid_dep} REG={rule_region}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _coordinate_seed_numeric_types(self, db_input, data_fields):
|
||||||
|
"""DB 种子值数字化(通用,无程序硬编码)。
|
||||||
|
|
||||||
|
适用:DB SELECT 种子列的值形如 'G0000001'(字母+数字,来自 alpha 合
|
||||||
|
成序列),但对应 COBOL 输出 FD 字段是 PIC 9(数字,如 SHA07REC 的
|
||||||
|
EMP-ID PIC 9(008))。运行时 MOVE 字母值到 PIC 9 非法 → 输出为空/0。
|
||||||
|
|
||||||
|
本関数:输出 FD(W01/W02 等)中 PIC 9 类型字段的 base 名(EMP-ID、
|
||||||
|
CHG-DATE、CHG-ID),对 DB 种子表中列名匹配的列,若值含非数字字符
|
||||||
|
则剥离非数字、左补零对齐 PIC 长度,转纯数字。字符字段(INSURER /
|
||||||
|
PREV / REASON / CHG-TYPE)不触碰。
|
||||||
|
|
||||||
|
検出はフィールド名パターン(PIC 9 + 出力 FD)+ 値パターン([A-Z]\\d+)
|
||||||
|
で行う。プログラム名ハードコードなし。
|
||||||
|
"""
|
||||||
|
if not db_input or not data_fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 输出 FD 前缀集合(W01/W02 等 OUTPUT FD)中 PIC 9 字段的 base 名
|
||||||
|
output_pref = set()
|
||||||
|
pic9_bases = {} # base 名(大写,去连字符)→ 数字位数
|
||||||
|
for f in data_fields:
|
||||||
|
if not isinstance(f, dict) or not f.get('pic') or f.get('is_88'):
|
||||||
|
continue
|
||||||
|
name = f['name']
|
||||||
|
m = re.match(r'^(W\d{2})(.*)$', name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
pref, rest = m.group(1), m.group(2)
|
||||||
|
pic = str(f.get('pic', ''))
|
||||||
|
if re.match(r'^9\((\d+)\)$', pic):
|
||||||
|
base = rest.lstrip('-').upper().replace('-', '_')
|
||||||
|
digits = int(re.match(r'^9\((\d+)\)$', pic).group(1))
|
||||||
|
output_pref.add(pref)
|
||||||
|
pic9_bases.setdefault(base, digits)
|
||||||
|
|
||||||
|
if not pic9_bases:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) 对每个 SELECT 种子表,数字化匹配的列
|
||||||
|
for table, rows in db_input.items():
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
for col in list(rows[0].keys()):
|
||||||
|
col_base = col.upper().replace('-', '_')
|
||||||
|
if col_base not in pic9_bases:
|
||||||
|
continue
|
||||||
|
digits = pic9_bases[col_base]
|
||||||
|
fixed = 0
|
||||||
|
for row in rows:
|
||||||
|
if col not in row:
|
||||||
|
continue
|
||||||
|
v = str(row[col]).strip()
|
||||||
|
if not v or v.isdigit():
|
||||||
|
continue
|
||||||
|
# 形如 'G0000001' → 剥离非数字 → '0000001' → 左补零到 digits
|
||||||
|
num = ''.join(ch for ch in v if ch.isdigit())
|
||||||
|
if not num:
|
||||||
|
continue
|
||||||
|
new_val = num.zfill(digits)[:digits]
|
||||||
|
if new_val != v:
|
||||||
|
row[col] = new_val
|
||||||
|
fixed += 1
|
||||||
|
if fixed:
|
||||||
|
logger.info(
|
||||||
|
f" DB 种子值数字化: {table}.{col} {fixed} 条→纯数字"
|
||||||
|
f"(PIC 9({digits}) 输出对齐)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _deduplicate_r01_pk(self, recs: list[dict]) -> int:
|
||||||
|
"""Ensure all R01 records have unique (EMP_ID, DATE) pairs.
|
||||||
|
|
||||||
|
After all patching, some records may share the same (EMP_ID, DATE),
|
||||||
|
causing PK violation in DAILY_RECORDS INSERT -> ABEND -> 3000STPSOR
|
||||||
|
not reached. Adjusts the day field for colliding records.
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
for i, rec in enumerate(recs):
|
||||||
|
eid = rec.get('R01EMP-ID', '')
|
||||||
|
dt = rec.get('R01DATE', '')
|
||||||
|
if not eid or not eid.strip() or eid == '00000000':
|
||||||
|
continue
|
||||||
|
if not dt or len(dt) < 8:
|
||||||
|
continue
|
||||||
|
ym = dt[:6]
|
||||||
|
groups.setdefault((eid, ym), []).append((i, dt[6:8]))
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for (eid, ym), entries in groups.items():
|
||||||
|
if len(entries) <= 1:
|
||||||
|
continue
|
||||||
|
used_days = set(d for _, d in entries)
|
||||||
|
if len(used_days) == len(entries):
|
||||||
|
continue
|
||||||
|
for idx, day in entries:
|
||||||
|
rec = recs[idx]
|
||||||
|
if sum(1 for _, d in entries if d == day) == 1:
|
||||||
|
continue
|
||||||
|
for dd in range(1, 32):
|
||||||
|
nd = f"{dd:02d}"
|
||||||
|
if nd not in used_days:
|
||||||
|
used_days.add(nd)
|
||||||
|
rec['R01DATE'] = ym + nd
|
||||||
|
line = rec.get('R01LINE', '')
|
||||||
|
if line:
|
||||||
|
parts = line.split(',')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
parts[1] = nd.ljust(8)
|
||||||
|
rec['R01LINE'] = ','.join(parts)
|
||||||
|
fixed += 1
|
||||||
|
logger.info(f" Dedup PK: rec[{idx}] (eid={eid} ym={ym}) day {day}->{nd}")
|
||||||
|
break
|
||||||
|
if fixed:
|
||||||
|
logger.info(f" Dedup PK: {fixed} record(s) adjusted")
|
||||||
|
return fixed
|
||||||
|
|
||||||
def _inject_sql_error_rows(self, db_path: Path, records: list[dict] | None = None):
|
def _inject_sql_error_rows(self, db_path: Path, records: list[dict] | None = None):
|
||||||
"""Insert duplicate-PK rows to trigger SQL error handling paths in COBOL."""
|
"""Insert duplicate-PK rows to trigger SQL error handling paths in COBOL.
|
||||||
|
|
||||||
|
PK 冲突行的 PK 必须与"运行时实际会被 INSERT"的记录一致(如 R01 记录),
|
||||||
|
否则程序 INSERT 时不会冲突。优先用测试记录的合成行(跳过会被清空 EMP-ID
|
||||||
|
的 records[0] 等特殊记录);表已有数据时逐行注入,而非固定取 rows[0]。
|
||||||
|
"""
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
for table in self.schema.db_tables:
|
for table in self.schema.db_tables:
|
||||||
pk_cols = [c.name for c in table.columns if c.primary_key]
|
pk_cols = [c.name for c in table.columns if c.primary_key]
|
||||||
@@ -1246,18 +1650,18 @@ class GixsqlOrchestrator:
|
|||||||
continue
|
continue
|
||||||
col_names = [c.name for c in table.columns]
|
col_names = [c.name for c in table.columns]
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall()
|
synthetic = self._make_synthetic_error_rows(table, records)
|
||||||
if len(rows) < 1:
|
if synthetic:
|
||||||
# For empty tables, generate synthetic error rows from test record data
|
rows = synthetic
|
||||||
synthetic = self._make_synthetic_error_rows(table, records)
|
else:
|
||||||
if synthetic:
|
# fallback: 表已有行
|
||||||
rows = synthetic
|
rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall()
|
||||||
else:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
quoted = ", ".join(f"[{c}]" for c in col_names)
|
quoted = ", ".join(f"[{c}]" for c in col_names)
|
||||||
ph = ", ".join("?" for _ in col_names)
|
ph = ", ".join("?" for _ in col_names)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
vals = tuple(str(rows[0][i]) if c in pk_cols else "X" for i, c in enumerate(col_names))
|
vals = tuple(str(row[i]) if c in pk_cols else "X" for i, c in enumerate(col_names))
|
||||||
conn.execute(f"INSERT OR IGNORE INTO [{table.name}] ({quoted}) VALUES ({ph})", vals)
|
conn.execute(f"INSERT OR IGNORE INTO [{table.name}] ({quoted}) VALUES ({ph})", vals)
|
||||||
logger.info(f" SQL error test row injected into {table.name}")
|
logger.info(f" SQL error test row injected into {table.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1265,6 +1669,163 @@ class GixsqlOrchestrator:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def _inject_extra_seed_rows(self, db_path: Path, scenario):
|
||||||
|
"""seed_extra_rows: 为 SELECT 型程序注入额外行(大结果集覆盖表头重出等分支)。
|
||||||
|
|
||||||
|
config: {table_name: count}。从该表已有 seed 行推导月份(date 列前 6 位,
|
||||||
|
如 DAILY_RECORDS 的 TARGET_DATE=202607xx),用唯一 EMP_ID + 当月日期
|
||||||
|
注入 count 行。通用实现:按表名注入,无程序硬编码。
|
||||||
|
"""
|
||||||
|
extra = getattr(scenario, 'seed_extra_rows', None)
|
||||||
|
if not extra:
|
||||||
|
return
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
try:
|
||||||
|
for table_name, count in extra.items():
|
||||||
|
table = next((t for t in self.schema.db_tables
|
||||||
|
if t.name == table_name), None)
|
||||||
|
if not table or not count or count <= 0:
|
||||||
|
continue
|
||||||
|
# 从现有 seed 行推导月份(PK 列中形如 YYYYMMDD 的值前 6 位)
|
||||||
|
sample = conn.execute(f"SELECT * FROM [{table_name}] LIMIT 1").fetchall()
|
||||||
|
month = None
|
||||||
|
for row in sample:
|
||||||
|
for i, c in enumerate(table.columns):
|
||||||
|
if c.primary_key:
|
||||||
|
v = str(row[i])
|
||||||
|
if len(v) >= 6 and v[:4].isdigit() and v[4:6].isdigit():
|
||||||
|
month = v[:6]
|
||||||
|
break
|
||||||
|
if month:
|
||||||
|
break
|
||||||
|
if not month:
|
||||||
|
logger.warning(f" seed_extra_rows: {table_name} 无月份可推导, 跳过")
|
||||||
|
continue
|
||||||
|
col_names = [c.name for c in table.columns]
|
||||||
|
quoted = ", ".join(f"[{c}]" for c in col_names)
|
||||||
|
ph = ", ".join("?" for _ in col_names)
|
||||||
|
inserted = 0
|
||||||
|
for i in range(count):
|
||||||
|
emp = f"SEED{i + 1:04d}"
|
||||||
|
vals = []
|
||||||
|
for c in table.columns:
|
||||||
|
if c.name == 'EMP_ID':
|
||||||
|
vals.append(emp)
|
||||||
|
elif c.name == 'TARGET_DATE':
|
||||||
|
vals.append(month + '01')
|
||||||
|
elif c.name == 'YEAR_MONTH':
|
||||||
|
vals.append(month)
|
||||||
|
else:
|
||||||
|
vals.append('0')
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT OR IGNORE INTO [{table_name}] ({quoted}) "
|
||||||
|
f"VALUES ({ph})", vals)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" seed_extra_rows inject skipped: {e}")
|
||||||
|
conn.commit()
|
||||||
|
logger.info(
|
||||||
|
f" seed_extra_rows: {table_name} 注入 {inserted} 条(月 {month})"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _inject_aggregation_boundaries(self, recs: list[dict]):
|
||||||
|
"""聚合边界数据注入(通用,无程序硬编码)。
|
||||||
|
|
||||||
|
目标分支(R01 集計型 DB 程序):
|
||||||
|
- AGG-ANNUAL-H ON SIZE ERROR:同 (EMP, 年月) 的 2+ 条记录设 *ANNUAL-H
|
||||||
|
为 PIC 最大值 → 累加溢出。
|
||||||
|
- AGG-COUNT < 100 的 ELSE:注入使不同 (EMP, 年月) 组合 >= 101 → 表满警告。
|
||||||
|
R01 记录字段按名称模式(R01*EMP-ID / R01*DATE / R01*ANNUAL-H)自动识别,
|
||||||
|
未命中即 no-op,不影响其他程序。
|
||||||
|
"""
|
||||||
|
if not recs or len(recs) < 5:
|
||||||
|
return
|
||||||
|
first = recs[0]
|
||||||
|
emp_f = date_f = hours_f = None
|
||||||
|
for k in first:
|
||||||
|
u = k.upper()
|
||||||
|
if u.startswith('R01'):
|
||||||
|
if u.endswith('EMP-ID') and not emp_f:
|
||||||
|
emp_f = k
|
||||||
|
elif 'ANNUAL' in u and ('-H' in u or 'HOURS' in u) and not hours_f:
|
||||||
|
hours_f = k
|
||||||
|
elif u.endswith('DATE') and 'WORK' not in u and 'APPL' not in u and not date_f:
|
||||||
|
date_f = k
|
||||||
|
if not (emp_f and date_f and hours_f):
|
||||||
|
return
|
||||||
|
|
||||||
|
# dup_eid = TARGET 最后批次(每批 8)的 EMP,保证被 T 卡片命中。
|
||||||
|
# 只考虑数字型 EMP(9(008) 字段的合法值);字母型 EMP(如 'U0000031')
|
||||||
|
# 对数字字段非法,写文件时会被转成 SPACE 而跳过。
|
||||||
|
all_ids = sorted({str(r.get(emp_f, '')).strip()
|
||||||
|
for r in recs
|
||||||
|
if str(r.get(emp_f, '')).strip().isdigit()
|
||||||
|
and str(r.get(emp_f, '')).strip() != '00000000'})
|
||||||
|
n = len(all_ids)
|
||||||
|
if n < 2:
|
||||||
|
return
|
||||||
|
dup_eid = all_ids[n - (n % 8 or 8)]
|
||||||
|
|
||||||
|
# 1) overflow:同 (EMP, 月) 的 2+ 条记录设 *ANNUAL-H 为 PIC 最大值
|
||||||
|
max_h = '9' * len(str(first.get(hours_f, '')))
|
||||||
|
src_idx = next((i for i, r in enumerate(recs)
|
||||||
|
if str(r.get(emp_f, '')).strip() == dup_eid), None)
|
||||||
|
if src_idx is not None and max_h:
|
||||||
|
dup_date = str(recs[src_idx].get(date_f, ''))
|
||||||
|
dup_ym = dup_date[:6]
|
||||||
|
recs[src_idx][hours_f] = max_h
|
||||||
|
used_days = {dup_date[6:8]} if len(dup_date) >= 8 else set()
|
||||||
|
changed = 0
|
||||||
|
for j in range(max(1, len(recs) - 3), len(recs)):
|
||||||
|
if j == src_idx:
|
||||||
|
continue
|
||||||
|
rec = recs[j]
|
||||||
|
rec[emp_f] = dup_eid
|
||||||
|
orig = str(rec.get(date_f, ''))
|
||||||
|
day = (orig[6:8] if orig and orig[6:8] not in used_days
|
||||||
|
else f"{len(used_days) + 1:02d}")
|
||||||
|
used_days.add(day)
|
||||||
|
rec[date_f] = dup_ym + day
|
||||||
|
rec[hours_f] = max_h
|
||||||
|
changed += 1
|
||||||
|
if changed >= 1:
|
||||||
|
logger.info(f" Agg overflow: {changed + 1} 条 {dup_eid} 同月 max={max_h}")
|
||||||
|
|
||||||
|
# 2) agg-full:保证 dup_eid 有 >=110 个不同月(其被 T 卡片命中聚合),
|
||||||
|
# 使 AGG-COUNT 超过 100 → 触发 AGG-COUNT < 100 的 ELSE(表满警告)
|
||||||
|
distinct = set()
|
||||||
|
for r in recs:
|
||||||
|
e = str(r.get(emp_f, '')).strip()
|
||||||
|
d = str(r.get(date_f, ''))
|
||||||
|
if e and e != '00000000' and len(d) >= 6:
|
||||||
|
distinct.add((e, d[:6]))
|
||||||
|
if dup_eid:
|
||||||
|
used_ym = {d[:6] for (e, d) in distinct if e == dup_eid}
|
||||||
|
template = dict(recs[1] if len(recs) > 1 else recs[0])
|
||||||
|
target = 110
|
||||||
|
added = 0
|
||||||
|
ym = 200001
|
||||||
|
while len(used_ym) < target:
|
||||||
|
ys = f"{ym:06d}"
|
||||||
|
if ys not in used_ym:
|
||||||
|
nr = dict(template)
|
||||||
|
nr[emp_f] = dup_eid
|
||||||
|
nr[date_f] = ys + '15'
|
||||||
|
recs.append(nr)
|
||||||
|
used_ym.add(ys)
|
||||||
|
distinct.add((dup_eid, ys))
|
||||||
|
added += 1
|
||||||
|
ym += 1
|
||||||
|
if ym > 209912:
|
||||||
|
break
|
||||||
|
if added:
|
||||||
|
logger.info(
|
||||||
|
f" Agg table full: 追加 {added} 条 {dup_eid} 不同月({dup_eid} 月数 {len(used_ym)})"
|
||||||
|
)
|
||||||
|
|
||||||
def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None,
|
def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None,
|
||||||
max_seed: int = 1,
|
max_seed: int = 1,
|
||||||
r01_dir: Path | None = None):
|
r01_dir: Path | None = None):
|
||||||
@@ -1353,42 +1914,60 @@ class GixsqlOrchestrator:
|
|||||||
logger.info(f" MONTHLY_ABSENCE: 0 seeded — all AGG entries will INSERT (DP#28 F)")
|
logger.info(f" MONTHLY_ABSENCE: 0 seeded — all AGG entries will INSERT (DP#28 F)")
|
||||||
|
|
||||||
def _make_synthetic_error_rows(self, table, records: list[dict] | None) -> list[tuple] | None:
|
def _make_synthetic_error_rows(self, table, records: list[dict] | None) -> list[tuple] | None:
|
||||||
"""Build synthetic error rows for an empty table from test record data."""
|
"""Build synthetic error rows from test record data.
|
||||||
|
|
||||||
|
冲突行的 PK 必须与运行时 INSERT 的实际值一致。运行时主机变量由输入记录
|
||||||
|
赋值(MOVE R01EMP-ID TO HV-EMP-ID 等),故优先取输入记录字段
|
||||||
|
(R01EMP-ID / R01DATE),YEAR_MONTH 由 R01DATE[:6] 推导,而非取值
|
||||||
|
尚未赋值的 WS 合成值(HV-* 在运行前是垃圾值,如 'A0000001')。
|
||||||
|
"""
|
||||||
if not records or len(records) < 2:
|
if not records or len(records) < 2:
|
||||||
return None
|
return None
|
||||||
pk_cols = [c.name for c in table.columns if c.primary_key]
|
pk_cols = [c.name for c in table.columns if c.primary_key]
|
||||||
if not pk_cols:
|
if not pk_cols:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Map COBOL host-variable names to table column names
|
# 列名 → 候选记录字段(输入记录字段优先,其次主机变量)
|
||||||
# KIN08DBU DAILY_RECORDS: EMP_ID=HV-EMP-ID, TARGET_DATE=HV-TARGET-DATE
|
|
||||||
# KIN08DBU MONTHLY_ABSENCE: EMP_ID=HV-EMP-ID, YEAR_MONTH=HV-YEAR-MONTH
|
|
||||||
hv_map = {
|
hv_map = {
|
||||||
'EMP_ID': ('HV-EMP-ID', 'R01EMP-ID', ''),
|
'EMP_ID': ('R01EMP-ID', 'HV-EMP-ID', ''),
|
||||||
'TARGET_DATE': ('HV-TARGET-DATE', ''),
|
'TARGET_DATE': ('R01DATE', 'HV-TARGET-DATE', ''),
|
||||||
'YEAR_MONTH': ('HV-YEAR-MONTH', ''),
|
'YEAR_MONTH': ('R01DATE', 'HV-YEAR-MONTH', ''),
|
||||||
'TIME_IN': ('HV-TIME-IN', ''),
|
'TIME_IN': ('R01TIME-IN', 'HV-TIME-IN', ''),
|
||||||
'TIME_OUT': ('HV-TIME-OUT', ''),
|
'TIME_OUT': ('R01TIME-OUT', 'HV-TIME-OUT', ''),
|
||||||
'ANNUAL_LEAVE_H': ('HV-ANNUAL-H', ''),
|
'ANNUAL_LEAVE_H': ('R01ANNUAL-H', 'HV-ANNUAL-H', ''),
|
||||||
'PERSONAL_LEAVE_H': ('HV-PERSONAL-H', ''),
|
'PERSONAL_LEAVE_H': ('R01PERSONAL-H', 'HV-PERSONAL-H', ''),
|
||||||
'OFFICIAL_LEAVE_H': ('HV-OFFICIAL-H', ''),
|
'OFFICIAL_LEAVE_H': ('R01OFFICIAL-H', 'HV-OFFICIAL-H', ''),
|
||||||
'SICK_LEAVE_H': ('HV-SICK-H', ''),
|
'SICK_LEAVE_H': ('R01SICK-H', 'HV-SICK-H', ''),
|
||||||
'UNAPPROVED_ABSENT_H': ('HV-ABSENT-H', ''),
|
'UNAPPROVED_ABSENT_H': ('R01ABSENT-H', 'HV-ABSENT-H', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _first(rec, keys):
|
||||||
|
for k in keys:
|
||||||
|
if k and k in rec:
|
||||||
|
return str(rec[k]).strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for idx in range(min(2, len(records))):
|
picked = 0
|
||||||
rec = records[idx]
|
for rec in records:
|
||||||
|
# 跳过会被清空 EMP-ID / 无效键的特殊记录(records[0] 等),
|
||||||
|
# 只选运行时确实会被 INSERT 的记录作为冲突 PK。
|
||||||
|
emp = _first(rec, hv_map.get('EMP_ID', ()))
|
||||||
|
if not emp or emp == '00000000':
|
||||||
|
continue
|
||||||
|
r01date = _first(rec, ('R01DATE', 'HV-TARGET-DATE'))
|
||||||
vals = []
|
vals = []
|
||||||
for col in table.columns:
|
for col in table.columns:
|
||||||
val = None
|
val = None
|
||||||
if col.name in hv_map:
|
if col.name == 'YEAR_MONTH':
|
||||||
for key in hv_map[col.name]:
|
val = r01date[:6] if len(r01date) >= 6 else ''
|
||||||
if key and key in rec:
|
elif col.name in hv_map:
|
||||||
val = rec[key]
|
val = _first(rec, hv_map[col.name]) or None
|
||||||
break
|
|
||||||
if val is None:
|
if val is None:
|
||||||
val = ' ' if col.name in pk_cols else ''
|
val = ' ' if col.name in pk_cols else ''
|
||||||
vals.append(str(val) if val is not None else '')
|
vals.append(str(val))
|
||||||
result.append(tuple(vals))
|
result.append(tuple(vals))
|
||||||
|
picked += 1
|
||||||
|
if picked >= 2:
|
||||||
|
break
|
||||||
return result if result else None
|
return result if result else None
|
||||||
|
|||||||
+174
-18
@@ -13,6 +13,57 @@ from typing import Optional
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_schema_qualifiers(text: str) -> str:
|
||||||
|
"""DB2 schema-qualified table refs (SCHEMA.TABLE) → TABLE.
|
||||||
|
|
||||||
|
SQLite has no schema objects, so gixsql-generated SQL such as
|
||||||
|
`FROM SALARYDB.EMP_MASTER` fails at OPEN time ('no such table:
|
||||||
|
SALARYDB.EMP_MASTER', SQLCODE != 0) and the program ABENDs before its
|
||||||
|
main loop. Strip the qualifier from table positions only
|
||||||
|
(after FROM/INTO/UPDATE/JOIN). Column aliases (E.EMP-ID, B.DEPT-CODE)
|
||||||
|
and host-var INTO (:DBV-X) are untouched. Program-agnostic: handles
|
||||||
|
ANY <schema>.<table>, incl. multi-part (DB2INST1.PAYROLL.TIMESHEET).
|
||||||
|
"""
|
||||||
|
def _fix(m: re.Match) -> str:
|
||||||
|
kw = m.group(1)
|
||||||
|
table = m.group(2).rsplit('.', 1)[-1]
|
||||||
|
return f"{kw} {table}"
|
||||||
|
return re.sub(
|
||||||
|
r'\b(FROM|INTO|UPDATE|JOIN)\s+([\w-]+(?:\.[\w-]+)+)',
|
||||||
|
_fix, text, flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_schema_qualifiers(text: str) -> str:
|
||||||
|
"""Strip schema qualifiers inside EXEC SQL blocks (before gixpp).
|
||||||
|
|
||||||
|
Applied to the source BEFORE gixpp so the generated SQL string literals
|
||||||
|
reference plain table names. Only EXEC SQL blocks are touched; other
|
||||||
|
text (data literals, comments) is left unchanged. Mirrors
|
||||||
|
`_normalize_current_timestamp`.
|
||||||
|
"""
|
||||||
|
def _fix_block(m: re.Match) -> str:
|
||||||
|
return _strip_schema_qualifiers(m.group(0))
|
||||||
|
return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_block, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_current_timestamp(text: str) -> str:
|
||||||
|
"""SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside EXEC SQL.
|
||||||
|
|
||||||
|
Applied to the source BEFORE gixpp. gixpp wraps long SQL across continuation
|
||||||
|
lines and can split the token (`CURRENT TIMES` / `TAMP`), which defeats a
|
||||||
|
post-gixpp same-line regex; normalizing the source first makes the generated
|
||||||
|
SQL string literals concatenate to the SQLite-valid `CURRENT_TIMESTAMP`.
|
||||||
|
Only EXEC SQL blocks are touched; other text (data literals, comments) is
|
||||||
|
left unchanged.
|
||||||
|
"""
|
||||||
|
def _fix_sql(m: re.Match) -> str:
|
||||||
|
block = m.group(0)
|
||||||
|
return re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP',
|
||||||
|
block, flags=re.IGNORECASE)
|
||||||
|
return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_sql, text)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GixsqlBuildResult:
|
class GixsqlBuildResult:
|
||||||
success: bool
|
success: bool
|
||||||
@@ -103,13 +154,13 @@ class GixsqlCobolRunner:
|
|||||||
return (
|
return (
|
||||||
" 01 SQLCA.\n"
|
" 01 SQLCA.\n"
|
||||||
" 05 SQLCAID PIC X(8).\n"
|
" 05 SQLCAID PIC X(8).\n"
|
||||||
" 05 SQLCABC PIC S9(9) COMP.\n"
|
" 05 SQLCABC PIC S9(9) COMP-5.\n"
|
||||||
" 05 SQLCODE PIC S9(9) COMP.\n"
|
" 05 SQLCODE PIC S9(9) COMP-5.\n"
|
||||||
" 05 SQLERRM.\n"
|
" 05 SQLERRM.\n"
|
||||||
" 49 SQLERRML PIC S9(4) COMP.\n"
|
" 49 SQLERRML PIC S9(4) COMP-5.\n"
|
||||||
" 49 SQLERRMC PIC X(256).\n"
|
" 49 SQLERRMC PIC X(256).\n"
|
||||||
" 05 SQLERRP PIC X(8).\n"
|
" 05 SQLERRP PIC X(8).\n"
|
||||||
" 05 SQLERRD PIC S9(9) COMP OCCURS 6.\n"
|
" 05 SQLERRD PIC S9(9) COMP-5 OCCURS 6.\n"
|
||||||
" 05 SQLWARN.\n"
|
" 05 SQLWARN.\n"
|
||||||
" 10 SQLWARN0 PIC X(1).\n"
|
" 10 SQLWARN0 PIC X(1).\n"
|
||||||
" 10 SQLWARN1 PIC X(1).\n"
|
" 10 SQLWARN1 PIC X(1).\n"
|
||||||
@@ -140,6 +191,10 @@ class GixsqlCobolRunner:
|
|||||||
"""
|
"""
|
||||||
text = src_path.read_text(encoding="utf-8-sig")
|
text = src_path.read_text(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
# 0. Strip ALL comment lines BEFORE any EXEC SQL transforms (regex may match
|
||||||
|
# 'EXEC SQL' inside Japanese comments, pulling comment text into SQL strings)
|
||||||
|
text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE)
|
||||||
|
|
||||||
# 1. Replace EXEC SQL INCLUDE SQLCA → COPY SQLCA
|
# 1. Replace EXEC SQL INCLUDE SQLCA → COPY SQLCA
|
||||||
text = re.sub(r'EXEC SQL INCLUDE SQLCA END-EXEC\.', ' COPY SQLCA.', text, flags=re.IGNORECASE)
|
text = re.sub(r'EXEC SQL INCLUDE SQLCA END-EXEC\.', ' COPY SQLCA.', text, flags=re.IGNORECASE)
|
||||||
# Transform EXEC SQL CONNECT TO 'literal' → use WS variables (gixpp requires :variable not literal)
|
# Transform EXEC SQL CONNECT TO 'literal' → use WS variables (gixpp requires :variable not literal)
|
||||||
@@ -154,17 +209,10 @@ class GixsqlCobolRunner:
|
|||||||
r"CONNECT\s+TO\s+'[^']*'",
|
r"CONNECT\s+TO\s+'[^']*'",
|
||||||
f"CONNECT TO :{conn_var} USER :{usr_var}",
|
f"CONNECT TO :{conn_var} USER :{usr_var}",
|
||||||
inner,
|
inner,
|
||||||
flags=re.IGNORECASE
|
flags=re.IGNORECASE | re.DOTALL
|
||||||
)
|
)
|
||||||
# Short absolute path under C:\Temp\gix\ (no Chinese chars, fits col 72).
|
# Orchestrator copies DB to CWD/kin.
|
||||||
# The orchestrator creates the DB at the same path so they match.
|
return (f"MOVE 'sqlite://kin' TO {conn_var}\n"
|
||||||
from pathlib import Path as _Path
|
|
||||||
pid = _Path(src_path).stem
|
|
||||||
gix_root = _Path("C:/Temp/gix")
|
|
||||||
gix_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
db_path = gix_root / f"{pid}.db"
|
|
||||||
conn_val = "sqlite:///" + str(db_path).replace("\\", "/")
|
|
||||||
return (f"MOVE '{conn_val}' TO {conn_var}\n"
|
|
||||||
f" MOVE 'gix' TO {usr_var}\n"
|
f" MOVE 'gix' TO {usr_var}\n"
|
||||||
f" EXEC SQL\n"
|
f" EXEC SQL\n"
|
||||||
f" {new_inner.strip()}\n"
|
f" {new_inner.strip()}\n"
|
||||||
@@ -185,9 +233,6 @@ class GixsqlCobolRunner:
|
|||||||
if hasattr(self, '_copybook_dirs') and self._copybook_dirs:
|
if hasattr(self, '_copybook_dirs') and self._copybook_dirs:
|
||||||
text = self._expand_all_copies(text, self._copybook_dirs)
|
text = self._expand_all_copies(text, self._copybook_dirs)
|
||||||
|
|
||||||
# 3. Strip ALL comment lines (* in any column 7-11)
|
|
||||||
text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE)
|
|
||||||
|
|
||||||
# 4. Collapse multiple spaces between keywords
|
# 4. Collapse multiple spaces between keywords
|
||||||
lines = []
|
lines = []
|
||||||
for line in text.splitlines(keepends=True):
|
for line in text.splitlines(keepends=True):
|
||||||
@@ -246,8 +291,29 @@ class GixsqlCobolRunner:
|
|||||||
text, count=1, flags=re.MULTILINE | re.IGNORECASE
|
text, count=1, flags=re.MULTILINE | re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 8. SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside
|
||||||
|
# EXEC SQL. Must run before gixpp (gixpp can split the token across
|
||||||
|
# continuation lines, defeating the post-gixpp same-line patch).
|
||||||
|
text = _normalize_current_timestamp(text)
|
||||||
|
|
||||||
|
# 9. SQLite backend: DB2 schema-qualified table refs (SCHEMA.TABLE) →
|
||||||
|
# TABLE inside EXEC SQL. gixsql would otherwise emit
|
||||||
|
# `FROM SALARYDB.EMP_MASTER`, which SQLite cannot resolve.
|
||||||
|
text = _normalize_schema_qualifiers(text)
|
||||||
|
|
||||||
norm_path = src_path.parent / f"{src_path.stem}_norm.cbl"
|
norm_path = src_path.parent / f"{src_path.stem}_norm.cbl"
|
||||||
norm_path.write_text(text, encoding="utf-8")
|
norm_path.write_text(text, encoding="utf-8")
|
||||||
|
# Save a copy in runtime for diagnosis
|
||||||
|
try:
|
||||||
|
debug_dir = Path(__file__).parent.parent / "runtime" / src_path.stem / "pre_src"
|
||||||
|
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(debug_dir / f"{src_path.stem}_norm.cbl").write_text(text, encoding="utf-8")
|
||||||
|
(debug_dir / f"{src_path.stem}_pre.cbl").write_text(
|
||||||
|
(src_path.parent / f"{src_path.stem}_pre.cbl").read_text(encoding="utf-8"),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return pre_path, norm_path
|
return pre_path, norm_path
|
||||||
|
|
||||||
def preprocess(self, src_path: str | Path, out_dir: str | Path,
|
def preprocess(self, src_path: str | Path, out_dir: str | Path,
|
||||||
@@ -273,6 +339,80 @@ class GixsqlCobolRunner:
|
|||||||
raise RuntimeError(f"gixpp failed (rc={r.returncode}): {err}")
|
raise RuntimeError(f"gixpp failed (rc={r.returncode}): {err}")
|
||||||
return str(out_path)
|
return str(out_path)
|
||||||
|
|
||||||
|
def _patch_sql_identifiers(self, pp_path: Path) -> None:
|
||||||
|
"""Translate DB2 hyphenated identifiers to underscores inside GIXSQL SQL strings.
|
||||||
|
|
||||||
|
gixsql emits the SQL text verbatim from the COBOL source (e.g.
|
||||||
|
"INSERT INTO EMP-MASTER (EMP-ID, ...)"), but SQLite cannot parse bare
|
||||||
|
hyphenated identifiers. Convert '-' -> '_' only inside the SQL string
|
||||||
|
literals (VALUE "..." lines and & "..." continuation lines), leaving
|
||||||
|
COBOL-level identifiers (cursor names, host variables, work items) untouched.
|
||||||
|
"""
|
||||||
|
text = pp_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def _fix_line(m: re.Match) -> str:
|
||||||
|
return m.group(1) + m.group(2).replace('-', '_') + m.group(3)
|
||||||
|
|
||||||
|
# SQL start lines: GIXSQL ... VALUE "SQL TEXT"
|
||||||
|
text = re.sub(
|
||||||
|
r'^(GIXSQL.*?VALUE\s+")([^"]*)(")',
|
||||||
|
_fix_line, text, flags=re.MULTILINE
|
||||||
|
)
|
||||||
|
# SQL continuation lines: GIXSQL & "SQL TEXT"
|
||||||
|
text = re.sub(
|
||||||
|
r'^(GIXSQL\s*&\s*")([^"]*)(")',
|
||||||
|
_fix_line, text, flags=re.MULTILINE
|
||||||
|
)
|
||||||
|
# SQLite accepts CURRENT_TIMESTAMP (no space); gixsql emits CURRENT TIMESTAMP
|
||||||
|
text = re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', text, flags=re.IGNORECASE)
|
||||||
|
pp_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
def _patch_sqlcode_normalize(self, pp_path: Path) -> None:
|
||||||
|
"""Inject SQLITE-constraint → -803 mapping after each SQL execution.
|
||||||
|
|
||||||
|
gixsql+SQLite reports duplicate-key (PRIMARY KEY / UNIQUE) violations as
|
||||||
|
SQLCODE=-1555 / -2067 / -19, whereas the COBOL programs follow DB2
|
||||||
|
semantics (SQLCODE = -803). Without this mapping, `IF SQLCODE = -803`
|
||||||
|
branches are unreachable under gixsql+SQLite. The injected code is
|
||||||
|
program-agnostic: it only rewrites the constraint-violation codes.
|
||||||
|
"""
|
||||||
|
text = pp_path.read_text(encoding="utf-8")
|
||||||
|
mapping = (
|
||||||
|
' IF SQLCODE = -1555\n'
|
||||||
|
' MOVE -803 TO SQLCODE\n'
|
||||||
|
' END-IF\n'
|
||||||
|
' IF SQLCODE = -2067\n'
|
||||||
|
' MOVE -803 TO SQLCODE\n'
|
||||||
|
' END-IF\n'
|
||||||
|
' IF SQLCODE = -19\n'
|
||||||
|
' MOVE -803 TO SQLCODE\n'
|
||||||
|
' END-IF\n'
|
||||||
|
)
|
||||||
|
text = re.sub(
|
||||||
|
r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+)',
|
||||||
|
lambda m: m.group(1) + '\n' + m.group(2) + mapping + m.group(2) + m.group(3),
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL
|
||||||
|
)
|
||||||
|
pp_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
def _patch_sqlcode_override(self, pp_path: Path) -> None:
|
||||||
|
"""Inject MOVE 0 TO SQLCODE after each GIXSQLEndSQL call.
|
||||||
|
|
||||||
|
Workaround for gixsql DLL bug: GIXSQLExecParams/GIXSQLExec fail with
|
||||||
|
'Can't find a connection' even though the connection is valid.
|
||||||
|
Setting SQLCODE=0 lets the program flow through success paths for
|
||||||
|
coverage measurement. The DB state is not validated in coverage runs.
|
||||||
|
"""
|
||||||
|
text = pp_path.read_text(encoding="utf-8")
|
||||||
|
text = re.sub(
|
||||||
|
r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+(?:NOT\s+)?=\s+0)',
|
||||||
|
r'\1\n\2 MOVE 0 TO SQLCODE\n\2\3',
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL
|
||||||
|
)
|
||||||
|
pp_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
def compile(self, pp_path: str | Path, exe_path: str | Path,
|
def compile(self, pp_path: str | Path, exe_path: str | Path,
|
||||||
copybook_dirs: list[str | Path] | None = None,
|
copybook_dirs: list[str | Path] | None = None,
|
||||||
extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult:
|
extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult:
|
||||||
@@ -282,6 +422,19 @@ class GixsqlCobolRunner:
|
|||||||
exe_dir = exe_path.parent
|
exe_dir = exe_path.parent
|
||||||
exe_dir.mkdir(parents=True, exist_ok=True)
|
exe_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# SQL is now executed correctly (hyphen->underscore identifiers), so the
|
||||||
|
# SQLCODE=0 override workaround is no longer needed. It masked real SQL
|
||||||
|
# errors and caused infinite FETCH loops (SQLCODE forced to 0 prevents
|
||||||
|
# "PERFORM UNTIL SQLCODE NOT = 0" from terminating at EOF=100).
|
||||||
|
# self._patch_sqlcode_override(pp_path)
|
||||||
|
|
||||||
|
# Translate DB2 hyphenated identifiers -> underscores in SQL strings
|
||||||
|
self._patch_sql_identifiers(pp_path)
|
||||||
|
|
||||||
|
# Map gixsql SQLite constraint codes (-1555/-2067/-19) -> DB2 -803 so
|
||||||
|
# `IF SQLCODE = -803` branches are reachable under gixsql+SQLite.
|
||||||
|
self._patch_sqlcode_normalize(pp_path)
|
||||||
|
|
||||||
# Functions that the preprocessed COBOL actually CALLs
|
# Functions that the preprocessed COBOL actually CALLs
|
||||||
gixsql_k = [
|
gixsql_k = [
|
||||||
"-K", "GIXSQLStartSQL",
|
"-K", "GIXSQLStartSQL",
|
||||||
@@ -331,7 +484,8 @@ class GixsqlCobolRunner:
|
|||||||
input_dir: str | Path | None = None,
|
input_dir: str | Path | None = None,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
cobol_lib_path: str | Path | None = None,
|
cobol_lib_path: str | Path | None = None,
|
||||||
env_overrides: dict[str, str] | None = None) -> GixsqlRunResult:
|
env_overrides: dict[str, str] | None = None,
|
||||||
|
command_args: list[str] | None = None) -> GixsqlRunResult:
|
||||||
"""Step 3: COBOL DB プログラム実行"""
|
"""Step 3: COBOL DB プログラム実行"""
|
||||||
exe_path = Path(exe_path)
|
exe_path = Path(exe_path)
|
||||||
work_dir = Path(work_dir)
|
work_dir = Path(work_dir)
|
||||||
@@ -388,6 +542,8 @@ class GixsqlCobolRunner:
|
|||||||
dst.write_bytes(f.read_bytes())
|
dst.write_bytes(f.read_bytes())
|
||||||
|
|
||||||
cmd = [str(exe_path)]
|
cmd = [str(exe_path)]
|
||||||
|
if command_args:
|
||||||
|
cmd.extend(command_args)
|
||||||
logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})")
|
logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})")
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
r = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||||
|
|||||||
Reference in New Issue
Block a user