feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs

This commit is contained in:
hangshuo652
2026-08-09 17:43:00 +08:00
parent f331c8fa2a
commit 273a3f8211
31 changed files with 3789 additions and 272 deletions
+642 -36
View File
@@ -1,4 +1,4 @@
"""SQL层:WHERE约束解析 + DB输入行生成"""
"""SQL层:WHERE约束解析 + DB输入行生成"""
import re
import logging
@@ -28,8 +28,97 @@ def _restore_strings(text: str, replacements: list) -> str:
# ── Bracket-aware AND splitting ──
def _split_on_AND(text: str) -> list[str]:
"""Split WHERE clause on AND, respecting parentheses."""
_RE_BETWEEN_SPAN = re.compile(r'\bBETWEEN\b', re.IGNORECASE)
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 = []
current = []
depth = 0
@@ -52,13 +141,51 @@ def _split_on_AND(text: str) -> list[str]:
current.append(token)
if current:
parts.append(' '.join(current).strip())
if not keep_placeholders:
parts = [_restore_between_placeholders(p, spans) for p in 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 ──
_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'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)',
re.IGNORECASE
@@ -70,7 +197,7 @@ _RE_IN_CLAUSE = 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
)
@@ -124,14 +251,15 @@ def _parse_where_condition(part: str, replacements: list) -> dict | None:
# BETWEEN
m = _RE_BETWEEN.match(part)
if m:
col = m.group(1).upper()
subj = m.group(1).upper()
neg = bool(m.group(2))
lo = m.group(3).strip()
hi = m.group(4).strip()
return {
'col': col, 'type': 'between', 'neg': neg,
'op': 'BETWEEN',
'lo': lo.strip("'\""), 'hi': hi.strip("'\""),
'type': 'between', 'neg': neg, 'op': 'BETWEEN',
'subject': _parse_between_operand(subj, replacements),
'lo': _parse_between_operand(lo, replacements),
'hi': _parse_between_operand(hi, replacements),
}
# LIKE
@@ -216,19 +344,28 @@ def sql_extract_constraints(where_clause: str, table: str,
# Protect string literals
cleaned, replacements = _protect_strings(where_clause)
# Split on AND
and_parts = _split_on_AND(cleaned)
# Protect BETWEEN spans, then split on AND
protected, spans = _scan_between_spans(cleaned)
and_parts = _split_on_AND(protected, spans=spans, keep_placeholders=True)
constraints = []
for part in and_parts:
part = part.strip()
if not part:
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:
# Map column to COBOL field
cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map)
cond['cobol_field'] = cobol_field
col_name = cond.get('col') or (cond.get('subject', {}) or {}).get('name')
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)
else:
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
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 ──
_COLUMN_DEFAULTS = {
@@ -251,8 +411,13 @@ _COLUMN_DEFAULTS = {
def _format_db_value(col_info: dict, raw_val: str) -> str:
db_type = col_info.get('db_type', 'CHAR')
formatter = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10])
default = formatter(0)
if db_type in ('CHAR', 'VARCHAR'):
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:
return default
if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
@@ -261,10 +426,11 @@ def _format_db_value(col_info: dict, raw_val: str) -> str:
except ValueError:
return default
return str(raw_val).ljust(len(default))[:len(default)]
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:
unique = f"{path_index:03d}{hash(key_val) % 100000:05d}"
seen_keys.add(unique)
@@ -291,12 +457,18 @@ def collect_sql_meta(assignments: dict, declared_columns: dict,
seen.add(key)
where = asgn.get('where', '')
table = asgn.get('table', '')
table = _norm_table(table)
where_constraints = sql_extract_constraints(
where, table, {}, column_map or {}, declared_columns
)
meta = dict(asgn)
meta['table'] = table
meta['where_constraints'] = where_constraints
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
@@ -311,6 +483,9 @@ def _path_has_sql_ok(path_cons: list) -> bool:
sql_ok = False
if pc[1] == '>' and pc[3]:
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
return sql_ok
@@ -345,6 +520,367 @@ def _parse_select_columns(select_list: str) -> list[str]:
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(
branch_paths: list[tuple[list, dict]],
fields_dict: list[dict],
@@ -352,6 +888,7 @@ def build_db_input(
sql_meta: list[dict],
declared_columns: dict,
records: list[dict] = None,
insert_pk: dict[str, list[str]] = None,
) -> dict:
"""Generate DB input rows per branch path.
Returns {table: [{col: val, ...}, ...]}.
@@ -363,14 +900,42 @@ def build_db_input(
seen_keys = {}
seq_counter = itertools.count(1)
# 事务调度感知:分类每条记录运行时执行的 SQLINSERT/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
for path_idx, (path_cons, path_assign) in enumerate(branch_paths):
# Skip paths where SQL fails (SQLCODE <> 0)
if not _path_has_sql_ok(path_cons):
continue
sql_ok = _path_has_sql_ok(path_cons)
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:
atype = sql.get('type', '')
table = sql.get('table', '')
@@ -383,12 +948,36 @@ def build_db_input(
seen_keys[table] = set()
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
if atype in ('exec_sql_delete', 'exec_sql_update'):
# 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:
col_infos = _infer_columns_from_where(where_cons)
row = {}
@@ -397,17 +986,17 @@ def build_db_input(
val = None
for wc in where_cons:
wc_col = wc.get('col', '').upper().split('.')[-1]
if wc_col != col_name:
if _norm_col(wc_col) != _norm_col(col_name):
continue
if wc['type'] == 'literal':
val = wc.get('literal', '')
break
elif wc['type'] == 'host_var':
hv = wc.get('host_var', '').upper()
val = str(rec.get(hv, ''))
val = _runtime_or_where_hostvar(rec, hv, assignments)
break
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():
row[ci['name']] = _format_db_value(ci, val)
if not row:
@@ -416,8 +1005,10 @@ def build_db_input(
continue
# exec_sql_select (and any future read-only types)
if not sql_ok:
continue
row = {}
col_infos = declared_columns.get(table, [])
col_infos = _declared_cols_for(declared_columns, table)
if not col_infos:
col_infos = _infer_columns_from_where(where_cons)
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
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})
where_cols = set()
@@ -439,35 +1030,48 @@ def build_db_input(
col_name = col_info['name']
val = None
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', '')
where_cols.add(col_name)
break
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()
for pc_field, pc_op, pc_val, pc_want in path_cons:
if pc_field == hv:
val = pc_val if pc_want else ''
where_cols.add(col_name)
break
if val is None and hv in rec:
val = str(rec[hv])
if val is None and _rec_has(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
if val is None:
for iv, scola in into_to_col.items():
if scola == col_name and iv in rec:
val = str(rec[iv])
if _norm_col(scola) == _norm_col(col_name) and _rec_has(rec, iv):
val = _rec_get(rec, iv, '')
break
# Try COBOL field name mapping
if val is None:
cobol_field = guess_cobol_field(col_name, table, declared_columns)
if cobol_field in rec:
val = str(rec[cobol_field])
if _rec_has(rec, cobol_field):
val = _rec_get(rec, cobol_field, '')
if val is not None:
row[col_name] = _format_db_value(col_info, val)
else:
row[col_name] = _format_db_value(col_info, str(next(seq_counter)))
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):
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)
return db_input