|
|
|
@@ -453,6 +453,254 @@ class GixsqlCobolRunner:
|
|
|
|
|
)
|
|
|
|
|
pp_path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _patch_numeric_metadata(pp_path: Path) -> None:
|
|
|
|
|
"""Fix gixpp numeric binding metadata for implicit-decimal DISPLAY fields.
|
|
|
|
|
|
|
|
|
|
gixpp emits ``GIXSQLSetSQLParams(1, length, power, ...)`` where ``power``
|
|
|
|
|
is negative for implicit-decimal PICs (e.g. ``9(4)V9(1)`` → power=-1).
|
|
|
|
|
gixsql uses ``bytes_read = length - power``; with ``length=5, power=-1``
|
|
|
|
|
this reads 6 bytes, one past the actual 5-byte field, corrupting the value
|
|
|
|
|
with the adjacent WORKING-STORAGE byte.
|
|
|
|
|
|
|
|
|
|
Fix: reduce ``length`` so that ``length - power = actual_field_size``
|
|
|
|
|
(the COBOL storage bytes), keeping ``power`` unchanged so gixsql still
|
|
|
|
|
reads the correct number of bytes.
|
|
|
|
|
"""
|
|
|
|
|
text = pp_path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
_CALL_RE = re.compile(
|
|
|
|
|
r'CALL "GIXSQLSetSQLParams" USING\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (-?\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE 0\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY REFERENCE \S+',
|
|
|
|
|
re.IGNORECASE | re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _fix_numeric(m: re.Match) -> str:
|
|
|
|
|
type_val = int(m.group(1))
|
|
|
|
|
length_val = int(m.group(2))
|
|
|
|
|
power_val = int(m.group(3))
|
|
|
|
|
if type_val == 1 and power_val < 0:
|
|
|
|
|
new_length = length_val + power_val # power is negative
|
|
|
|
|
logger.debug(f" _patch_numeric_metadata: length {length_val}->{new_length}, "
|
|
|
|
|
f"power unchanged {power_val}")
|
|
|
|
|
old = m.group(0)
|
|
|
|
|
old_lines = old.split('\n')
|
|
|
|
|
new_lines = []
|
|
|
|
|
val_idx = 0
|
|
|
|
|
for line in old_lines:
|
|
|
|
|
if 'BY VALUE' in line:
|
|
|
|
|
val_idx += 1
|
|
|
|
|
if val_idx == 1: # type
|
|
|
|
|
new_lines.append(line)
|
|
|
|
|
elif val_idx == 2: # length — reduce to actual storage size
|
|
|
|
|
new_lines.append(re.sub(r'BY VALUE \d+', f'BY VALUE {new_length}', line))
|
|
|
|
|
else: # power, flag — keep unchanged
|
|
|
|
|
new_lines.append(line)
|
|
|
|
|
else:
|
|
|
|
|
new_lines.append(line)
|
|
|
|
|
return '\n'.join(new_lines)
|
|
|
|
|
return m.group(0)
|
|
|
|
|
|
|
|
|
|
text = _CALL_RE.sub(_fix_numeric, text)
|
|
|
|
|
pp_path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def extract_power_scaling(pp_path: str | Path) -> dict[str, int]:
|
|
|
|
|
"""Extract power scaling info from _pp.cbl for DB post-processing.
|
|
|
|
|
|
|
|
|
|
Strategy:
|
|
|
|
|
1. Parse INSERT SQL from FILLER VALUE strings → get column lists.
|
|
|
|
|
2. Scan GIXSQLSetSQLParams in order → find which positions have
|
|
|
|
|
type=1, power<0 (implicit-decimal numeric).
|
|
|
|
|
3. Map positions to columns. Since the same column names appear
|
|
|
|
|
in multiple INSERTs, we just need the unique column names.
|
|
|
|
|
|
|
|
|
|
Returns mapping e.g. {"ANNUAL_LEAVE_H": -1, ...}.
|
|
|
|
|
"""
|
|
|
|
|
raw_text = Path(pp_path).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
# Step 1: Extract INSERT SQL column→position mappings from FILLER VALUEs.
|
|
|
|
|
_FILLER_RE = re.compile(
|
|
|
|
|
r'02\s+FILLER\s+PIC\s+X\(\d+\)\s+VALUE\s+"([^"]*)"'
|
|
|
|
|
r'((?:\s*\n\s*GIXSQL\s*&\s+"[^"]*")*)',
|
|
|
|
|
re.IGNORECASE
|
|
|
|
|
)
|
|
|
|
|
# Collect ALL columns that use $N params (across all INSERTs)
|
|
|
|
|
all_numeric_cols: set[str] = set()
|
|
|
|
|
max_position = 0
|
|
|
|
|
first_pos_map: dict[int, str] = {} # first occurrence of each position
|
|
|
|
|
|
|
|
|
|
for m in _FILLER_RE.finditer(raw_text):
|
|
|
|
|
first = m.group(1)
|
|
|
|
|
conts = m.group(2)
|
|
|
|
|
cont_texts = re.findall(r'"([^"]*)"', conts)
|
|
|
|
|
full_sql = first + ''.join(cont_texts)
|
|
|
|
|
ins = re.search(
|
|
|
|
|
r'INSERT\s+INTO\s+\w+\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)',
|
|
|
|
|
full_sql, re.IGNORECASE
|
|
|
|
|
)
|
|
|
|
|
if ins:
|
|
|
|
|
cols = [c.strip() for c in ins.group(1).split(',')]
|
|
|
|
|
vals = [v.strip() for v in ins.group(2).split(',')]
|
|
|
|
|
for col, val in zip(cols, vals):
|
|
|
|
|
pm = re.match(r'\$(\d+)', val)
|
|
|
|
|
if pm:
|
|
|
|
|
pos = int(pm.group(1))
|
|
|
|
|
max_position = max(max_position, pos)
|
|
|
|
|
if pos not in first_pos_map:
|
|
|
|
|
first_pos_map[pos] = col
|
|
|
|
|
|
|
|
|
|
# Step 2: Extract GIXSQLSetSQLParams calls in order.
|
|
|
|
|
_PARAM_RE = re.compile(
|
|
|
|
|
r'CALL "GIXSQLSetSQLParams" USING\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE (-?\d+)\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY VALUE 0\s+'
|
|
|
|
|
r'(?:GIXSQL\s+)?BY REFERENCE (\S+)',
|
|
|
|
|
re.IGNORECASE | re.DOTALL
|
|
|
|
|
)
|
|
|
|
|
all_params = []
|
|
|
|
|
for m in _PARAM_RE.finditer(raw_text):
|
|
|
|
|
all_params.append((int(m.group(1)), int(m.group(3)), m.group(4)))
|
|
|
|
|
|
|
|
|
|
# Step 3: Group params by their INSERT context.
|
|
|
|
|
# Each INSERT has its own $1..$N. The params repeat for each SQL
|
|
|
|
|
# statement (INSERT DAILY, DELETE, SELECT, UPDATE, INSERT MONTHLY, ...).
|
|
|
|
|
# We need to find which param indices map to which INSERT's $N.
|
|
|
|
|
#
|
|
|
|
|
# Key insight: for each INSERT, the $N positions are 1..N.
|
|
|
|
|
# The SetSQLParams before that INSERT are in the same order.
|
|
|
|
|
# We can identify INSERT boundaries by looking for GIXSQLExecParams
|
|
|
|
|
# calls that reference the SQL string (SQ0001, SQ0002, etc.).
|
|
|
|
|
#
|
|
|
|
|
# Simpler approach: since all INSERTs share the same numeric columns,
|
|
|
|
|
# just find ALL columns that appear at ANY $N position, and ALL
|
|
|
|
|
# SetSQLParams with type=1/power<0. Then match by relative position
|
|
|
|
|
# within each INSERT group.
|
|
|
|
|
#
|
|
|
|
|
# Even simpler: just check which columns from the DDL appear in
|
|
|
|
|
# ANY INSERT with a $N param, and which SetSQLParams have power<0.
|
|
|
|
|
# The SetSQLParams with power<0 always correspond to the numeric
|
|
|
|
|
# columns (DECIMAL), so we can match by counting.
|
|
|
|
|
|
|
|
|
|
# Count how many params are BEFORE the first numeric param
|
|
|
|
|
# and how many numeric params there are per INSERT.
|
|
|
|
|
# For DAILY_RECORDS: 4 string params, then 5 numeric, then 1 timestamp
|
|
|
|
|
# For MONTHLY_ABSENCE: 2 string params, then 5 numeric, then 1 timestamp
|
|
|
|
|
|
|
|
|
|
# Find all unique column names that have $N in INSERT SQL
|
|
|
|
|
numeric_cols_in_insert: list[str] = []
|
|
|
|
|
for pos in sorted(first_pos_map.keys()):
|
|
|
|
|
col = first_pos_map[pos]
|
|
|
|
|
# We'll filter to just the numeric ones after matching
|
|
|
|
|
|
|
|
|
|
# Match: SetSQLParams with type=1/power<0 → columns at corresponding $N
|
|
|
|
|
# We need to know which $N corresponds to which SetSQLParams index.
|
|
|
|
|
#
|
|
|
|
|
# Strategy: find the first INSERT's column list, then find the
|
|
|
|
|
# SetSQLParams group that precedes it. The numeric columns in the
|
|
|
|
|
# INSERT are at specific $N positions. The SetSQLParams with
|
|
|
|
|
# type=1/power<0 are at corresponding indices.
|
|
|
|
|
|
|
|
|
|
# Find the first INSERT DAILY_RECORDS column order
|
|
|
|
|
daily_cols = []
|
|
|
|
|
for m in _FILLER_RE.finditer(raw_text):
|
|
|
|
|
first = m.group(1)
|
|
|
|
|
conts = m.group(2)
|
|
|
|
|
cont_texts = re.findall(r'"([^"]*)"', conts)
|
|
|
|
|
full_sql = first + ''.join(cont_texts)
|
|
|
|
|
ins = re.search(
|
|
|
|
|
r'INSERT\s+INTO\s+DAILY_RECORDS\s*\(([^)]+)\)',
|
|
|
|
|
full_sql, re.IGNORECASE
|
|
|
|
|
)
|
|
|
|
|
if ins:
|
|
|
|
|
daily_cols = [c.strip() for c in ins.group(1).split(',')]
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Find which $N positions in DAILY_RECORDS are numeric (DECIMAL in DDL)
|
|
|
|
|
# by checking if the corresponding SetSQLParams has type=1/power<0.
|
|
|
|
|
# The $N for DAILY_RECORDS: EMP_ID=$1, TARGET_DATE=$2, ..., UPDATED_AT=$9(→CURRENT_TIMESTAMP)
|
|
|
|
|
# We know the SetSQLParams before the first INSERT are in order $1..$9.
|
|
|
|
|
# Count params before the first GIXSQLExecParams (which executes the INSERT).
|
|
|
|
|
|
|
|
|
|
# Find the first GIXSQLExecParams position
|
|
|
|
|
_EXEC_RE = re.compile(r'CALL "GIXSQLExecParams"', re.IGNORECASE)
|
|
|
|
|
first_exec = _EXEC_RE.search(raw_text)
|
|
|
|
|
first_exec_pos = first_exec.start() if first_exec else len(raw_text)
|
|
|
|
|
|
|
|
|
|
# Collect params that appear BEFORE the first exec
|
|
|
|
|
pre_exec_params = []
|
|
|
|
|
for ptype, ppower, pvar in all_params:
|
|
|
|
|
var_pattern = re.compile(rf'BY REFERENCE {re.escape(pvar)}\b', re.IGNORECASE)
|
|
|
|
|
var_match = var_pattern.search(raw_text)
|
|
|
|
|
if var_match and var_match.start() < first_exec_pos:
|
|
|
|
|
pre_exec_params.append((ptype, ppower, pvar))
|
|
|
|
|
|
|
|
|
|
# These are the params for the first INSERT (DAILY_RECORDS).
|
|
|
|
|
# Match them to the DAILY_RECORDS columns.
|
|
|
|
|
scaling: dict[str, int] = {}
|
|
|
|
|
for idx, (ptype, ppower, pvar) in enumerate(pre_exec_params):
|
|
|
|
|
pos = idx + 1 # 1-based
|
|
|
|
|
if ptype == 1 and ppower < 0:
|
|
|
|
|
col_name = first_pos_map.get(pos)
|
|
|
|
|
if col_name:
|
|
|
|
|
scaling[col_name] = ppower
|
|
|
|
|
|
|
|
|
|
return scaling
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def apply_power_scaling(db_path: str | Path,
|
|
|
|
|
power_map: dict[str, int]) -> int:
|
|
|
|
|
"""Post-process SQLite DB: divide numeric columns by 10^|power|.
|
|
|
|
|
|
|
|
|
|
gixsql stores raw digits (e.g. 402) for implicit-decimal fields
|
|
|
|
|
but does NOT apply the power scaling. This corrects the DB values
|
|
|
|
|
to match the intended COBOL decimal representation (e.g. 40.2).
|
|
|
|
|
|
|
|
|
|
Returns number of rows updated.
|
|
|
|
|
"""
|
|
|
|
|
if not power_map:
|
|
|
|
|
return 0
|
|
|
|
|
db_path = Path(db_path)
|
|
|
|
|
if not db_path.exists():
|
|
|
|
|
return 0
|
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
|
|
|
total_updated = 0
|
|
|
|
|
for var_name, power in power_map.items():
|
|
|
|
|
col_name = var_name.replace('-', '_')
|
|
|
|
|
scale_factor = 10 ** abs(power)
|
|
|
|
|
try:
|
|
|
|
|
# Get table names that have this column
|
|
|
|
|
tables = conn.execute(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
|
|
|
).fetchall()
|
|
|
|
|
for (tname,) in tables:
|
|
|
|
|
cols = conn.execute(f"PRAGMA table_info([{tname}])").fetchall()
|
|
|
|
|
col_names = [c[1] for c in cols]
|
|
|
|
|
if col_name in col_names:
|
|
|
|
|
# Use CAST to force float division (SQLite integer
|
|
|
|
|
# division truncates: 402/10=40, need 402.0/10=40.2)
|
|
|
|
|
cur = conn.execute(
|
|
|
|
|
f"UPDATE [{tname}] SET [{col_name}] = CAST([{col_name}] AS REAL) / ?",
|
|
|
|
|
(scale_factor,)
|
|
|
|
|
)
|
|
|
|
|
if cur.rowcount > 0:
|
|
|
|
|
logger.info(
|
|
|
|
|
f" power_scale: {tname}.{col_name} /= {scale_factor} "
|
|
|
|
|
f"({cur.rowcount} rows, power={power})"
|
|
|
|
|
)
|
|
|
|
|
total_updated += cur.rowcount
|
|
|
|
|
except sqlite3.Error as e:
|
|
|
|
|
logger.warning(f" power_scale: {tname}.{col_name} failed: {e}")
|
|
|
|
|
conn.commit()
|
|
|
|
|
conn.close()
|
|
|
|
|
return total_updated
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
@@ -475,6 +723,11 @@ class GixsqlCobolRunner:
|
|
|
|
|
# `IF SQLCODE = -803` branches are reachable under gixsql+SQLite.
|
|
|
|
|
self._patch_sqlcode_normalize(pp_path)
|
|
|
|
|
|
|
|
|
|
# Fix gixpp numeric binding metadata for implicit-decimal DISPLAY fields.
|
|
|
|
|
# gixpp emits power=-1 for 9(4)V9(1), causing gixsql to read 6 bytes
|
|
|
|
|
# (length - power = 5-(-1)=6) and corrupt the value with adjacent data.
|
|
|
|
|
self._patch_numeric_metadata(pp_path)
|
|
|
|
|
|
|
|
|
|
# Functions that the preprocessed COBOL actually CALLs
|
|
|
|
|
gixsql_k = [
|
|
|
|
|
"-K", "GIXSQLStartSQL",
|
|
|
|
|