feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs
This commit is contained in:
+642
-36
@@ -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)
|
||||
|
||||
# 事务调度感知:分类每条记录运行时执行的 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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user