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
+174 -18
View File
@@ -13,6 +13,57 @@ from typing import Optional
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
class GixsqlBuildResult:
success: bool
@@ -103,13 +154,13 @@ class GixsqlCobolRunner:
return (
" 01 SQLCA.\n"
" 05 SQLCAID PIC X(8).\n"
" 05 SQLCABC PIC S9(9) COMP.\n"
" 05 SQLCODE PIC S9(9) COMP.\n"
" 05 SQLCABC PIC S9(9) COMP-5.\n"
" 05 SQLCODE PIC S9(9) COMP-5.\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"
" 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"
" 10 SQLWARN0 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")
# 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
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)
@@ -154,17 +209,10 @@ class GixsqlCobolRunner:
r"CONNECT\s+TO\s+'[^']*'",
f"CONNECT TO :{conn_var} USER :{usr_var}",
inner,
flags=re.IGNORECASE
flags=re.IGNORECASE | re.DOTALL
)
# Short absolute path under C:\Temp\gix\ (no Chinese chars, fits col 72).
# The orchestrator creates the DB at the same path so they match.
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"
# Orchestrator copies DB to CWD/kin.
return (f"MOVE 'sqlite://kin' TO {conn_var}\n"
f" MOVE 'gix' TO {usr_var}\n"
f" EXEC SQL\n"
f" {new_inner.strip()}\n"
@@ -185,9 +233,6 @@ class GixsqlCobolRunner:
if hasattr(self, '_copybook_dirs') and 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
lines = []
for line in text.splitlines(keepends=True):
@@ -246,8 +291,29 @@ class GixsqlCobolRunner:
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.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
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}")
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,
copybook_dirs: list[str | Path] | None = None,
extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult:
@@ -282,6 +422,19 @@ class GixsqlCobolRunner:
exe_dir = exe_path.parent
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
gixsql_k = [
"-K", "GIXSQLStartSQL",
@@ -331,7 +484,8 @@ class GixsqlCobolRunner:
input_dir: str | Path | None = None,
timeout: int = 30,
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 プログラム実行"""
exe_path = Path(exe_path)
work_dir = Path(work_dir)
@@ -388,6 +542,8 @@ class GixsqlCobolRunner:
dst.write_bytes(f.read_bytes())
cmd = [str(exe_path)]
if command_args:
cmd.extend(command_args)
logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})")
try:
r = subprocess.run(cmd, capture_output=True, timeout=timeout,