from __future__ import annotations
import datetime as _dt
import re
from collections import Counter
from decimal import Decimal
from typing import Optional
FLOAT_TOL = 6
_FRAC_SECONDS = re.compile(r"(:\d{2})\.(\d+)")
def _strip_frac(m: "re.Match") -> str:
frac = m.group(2).rstrip("0")
return m.group(1) + ("." + frac if frac else "")
def canon_scalar(v):
if v is None:
return None
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float, Decimal)):
return round(float(v), FLOAT_TOL)
if isinstance(v, _dt.datetime):
return _FRAC_SECONDS.sub(_strip_frac, v.isoformat(sep=" "))
if isinstance(v, _dt.date):
return v.isoformat()
if isinstance(v, _dt.time):
return _FRAC_SECONDS.sub(_strip_frac, v.isoformat())
s = str(v).strip()
if s == "":
return None
low = s.lower()
if low in ("true", "false"):
return low
try:
return round(float(s), FLOAT_TOL)
except ValueError:
pass
return _FRAC_SECONDS.sub(_strip_frac, s.replace("T", " "))
def canon_row(row: dict) -> dict:
return {str(k).strip().lower(): canon_scalar(v) for k, v in row.items()}
def _col_set(rows: list[dict]) -> set:
cols: set = set()
for r in rows:
cols.update(r.keys())
return cols
def _fmt(rows, cols, limit=3) -> str:
shown = [dict(zip(cols, r)) for r in rows[:limit]]
extra = "" if len(rows) <= limit else f" (+{len(rows) - limit} more)"
return "; ".join(str(s) for s in shown) + extra
def has_order_by(sql: str) -> bool:
return "order by" in sql.lower()
def compare(sqlcli_rows: list[dict], ref_rows: list[dict], ordered: bool) -> tuple[bool, Optional[str]]:
a = [canon_row(r) for r in sqlcli_rows]
b = [canon_row(r) for r in ref_rows]
ca, cb = _col_set(a), _col_set(b)
if ca != cb:
only_a = sorted(ca - cb)
only_b = sorted(cb - ca)
parts = []
if only_a:
parts.append(f"sql-cli-only cols={only_a}")
if only_b:
parts.append(f"ref-only cols={only_b}")
return False, "column mismatch: " + ", ".join(parts) + " (alias computed columns to align)"
cols = sorted(ca)
ta = [tuple(r.get(c) for c in cols) for r in a]
tb = [tuple(r.get(c) for c in cols) for r in b]
if ordered:
if ta == tb:
return True, None
msg = [f"rows: sql-cli={len(ta)} ref={len(tb)} (ordered)"]
for i, (x, y) in enumerate(zip(ta, tb)):
if x != y:
msg.append(f" first diff at row {i}:")
msg.append(f" sql-cli: {dict(zip(cols, x))}")
msg.append(f" ref: {dict(zip(cols, y))}")
break
return False, "\n".join(msg)
ca_count, cb_count = Counter(ta), Counter(tb)
if ca_count == cb_count:
return True, None
only_a = list((ca_count - cb_count).elements())
only_b = list((cb_count - ca_count).elements())
msg = [f"rows: sql-cli={len(ta)} ref={len(tb)}"]
if only_a:
msg.append(f" only in sql-cli ({len(only_a)}): {_fmt(only_a, cols)}")
if only_b:
msg.append(f" only in ref ({len(only_b)}): {_fmt(only_b, cols)}")
return False, "\n".join(msg)