import hashlib from datetime import UTC, datetime from pathlib import Path import typer from tht.cli.config_cmd import CONFIG_OPT from tht.cli.schema_cmd import _load_config_or_exit from tht.cli.session_cmd import load_session_or_exit, session_dir from tht.cli.sql_cmd import promoted_tables_for, require_action _RULE6_HINT = ( " — il file del CTE deve contenere SOLO il blocco WITH ... AS (...), " "senza SELECT finale: la SELECT la aggiunge tht in fase di test." ) cte_app = typer.Typer(help="Test controllato dei CTE proposti (Agent View Generation)") @cte_app.command("test") def test_cmd( name: str = typer.Argument(..., help="Nome del CTE (file sessions//ctes/.sql)."), session: str = typer.Option(..., "--session"), json_out: bool = typer.Option(False, "--json", help="Emette l'esito come JSON (per Pi)."), config: Path = CONFIG_OPT, ) -> None: """Valida e testa un CTE: wrap su ultimo CTE + esecuzione nelle 4 reti.""" from rich.console import Console from rich.table import Table from tht.cli.sql_cmd import do_run from tht.ctetest import CteError, CteTestRecord, append_cte_test, build_test_sql, has_trailing_select from tht.execute import ExecutionError from tht.execute.warnings import runtime_warnings cfg = _load_config_or_exit(config) require_action(cfg, "cte_test") load_session_or_exit(cfg, session) sdir = session_dir(cfg, session) from tht.cli.phase_cmd import require_phase_or_exit from tht.phase import next_cte require_phase_or_exit(cfg, session, 6) nxt = next_cte(sdir) if nxt is not None and name != nxt: typer.secho( f"ERRORE: ordine CTE. Ora tocca a '{nxt}' (il primo CTE del piano non " f"ancora approvato), non a '{name}'. Testa '{nxt}', poi falla approvare " f"dal reviewer (decisione cte_approved); solo allora potrai testare il " f"CTE successivo.", fg=typer.colors.RED, err=True, ) raise typer.Exit(code=5) cte_file = sdir / "ctes" / f"{name}.sql" if not cte_file.exists(): typer.secho(f"ERRORE: file CTE non trovato: {cte_file}", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) cte_sql = cte_file.read_text() sql_hash = hashlib.sha256(cte_sql.encode()).hexdigest() def _record_error(message: str) -> None: record = CteTestRecord( name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="error", error=message, ) append_cte_test(sdir, record) if json_out: typer.echo(record.model_dump_json()) else: typer.secho(f"ERRORE: {message}", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) try: test_sql = build_test_sql(cte_sql) except CteError as e: msg = str(e) if "non parsabile" in msg and has_trailing_select(cte_sql): msg += _RULE6_HINT _record_error(msg) # validazione statica (esce con messaggi propri se invalida; qui la # intercettiamo per registrare comunque l'esito nel cte_tests.json) from tht.cli.sql_cmd import _load_physical_or_exit from tht.sqlcheck import validate_sql check = validate_sql( test_sql, physical=_load_physical_or_exit(cfg), promoted_tables=promoted_tables_for(cfg, session), forbidden_functions=set(cfg.execution.forbidden_functions), ) for w in check.warnings: typer.secho(f" warning: {w}", fg=typer.colors.YELLOW) if not check.ok: msg = "; ".join(check.errors) if any("statement" in e for e in check.errors): msg += _RULE6_HINT _record_error(msg) try: result = do_run(cfg, test_sql, limit=cfg.execution.max_preview_rows) except ExecutionError as e: _record_error(str(e)) warnings = runtime_warnings(result, cfg.execution) record = CteTestRecord( name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="ok", columns=result.columns, row_sample=len(result.rows), execution_ms=result.execution_ms, warnings=warnings, ) append_cte_test(sdir, record) if json_out: typer.echo(record.model_dump_json()) return typer.echo(f"CTE {name} — {result.execution_ms} ms") table = Table() for col in result.columns: table.add_column(col) for row in result.rows: table.add_row(*[str(v) for v in row]) Console().print(table) for w in warnings: typer.secho(f" warning: {w}", fg=typer.colors.YELLOW) typer.secho(f"OK: esito registrato in {sdir / 'cte_tests.json'}", fg=typer.colors.GREEN) @cte_app.command("plan") def plan_cmd( session: str = typer.Option(..., "--session"), name: list[str] = typer.Option(..., "--name", help="Nome CTE (ripetibile, in ordine)."), config: Path = CONFIG_OPT, ) -> None: """Persiste il piano CTE ordinato (sessions//cte_plan.json).""" import json from tht.phase import CTE_PLAN_FILE cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session) from tht.cli.phase_cmd import require_phase_or_exit require_phase_or_exit(cfg, session, 6) sdir = session_dir(cfg, session) sdir.mkdir(parents=True, exist_ok=True) (sdir / CTE_PLAN_FILE).write_text(json.dumps(name, ensure_ascii=False)) typer.secho(f"OK: piano CTE salvato ({len(name)} CTE) in {sdir / CTE_PLAN_FILE}.", fg=typer.colors.GREEN) @cte_app.command("next") def next_cmd( session: str = typer.Option(..., "--session"), config: Path = CONFIG_OPT, ) -> None: """Primo CTE del piano non ancora approvato (stdout pulito; vuoto se nessuno). Sorgente unica dell'ordine di approvazione CTE (F6): il gate lo usa per registrare cte_approved sul NOME del CTE (non su 'phase:6').""" from tht.phase import next_cte cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session) nxt = next_cte(session_dir(cfg, session)) if nxt: typer.echo(nxt) @cte_app.command("list") def list_cmd( session: str = typer.Option(..., "--session"), config: Path = CONFIG_OPT, ) -> None: """Ultimo esito registrato per ogni CTE della sessione.""" from tht.ctetest import CteError, load_cte_tests cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session) try: records = load_cte_tests(session_dir(cfg, session)) except CteError as e: typer.secho(f"ERRORE: impossibile leggere cte_tests.json: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) if not records: typer.echo("Nessun test CTE registrato.") return latest = {} for r in records: latest[r.name] = r for name, r in sorted(latest.items()): line = f"{name}: {r.status} ({r.ts:%Y-%m-%d %H:%M}, {r.execution_ms} ms)" if r.error: line += f" — {r.error}" if r.warnings: line += f" [{len(r.warnings)} warning]" typer.echo(line)