327 lines
12 KiB
Python
327 lines
12 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from tht.cli.config_cmd import CONFIG_OPT
|
|
from tht.cli.schema_cmd import _load_config_or_exit, physical_path
|
|
|
|
sql_app = typer.Typer(help="Validazione ed esecuzione controllata di SQL (read-only)")
|
|
|
|
|
|
def _read_sql(file: Path) -> str:
|
|
if not file.exists():
|
|
typer.secho(f"ERRORE: file non trovato: {file}", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
return file.read_text()
|
|
|
|
|
|
def _load_physical_or_exit(cfg):
|
|
from tht.mschema.models import PhysicalSchema
|
|
|
|
phys_file = physical_path(cfg)
|
|
if not phys_file.exists():
|
|
typer.secho(
|
|
f"ERRORE: {phys_file} non trovato. Esegui prima `tht schema introspect`.",
|
|
fg=typer.colors.RED, err=True,
|
|
)
|
|
raise typer.Exit(code=1)
|
|
return PhysicalSchema.from_yaml(phys_file)
|
|
|
|
|
|
def require_action(cfg, action: str) -> None:
|
|
if action not in cfg.execution.allow:
|
|
typer.secho(
|
|
f"ERRORE: azione '{action}' non consentita dalla policy "
|
|
f"(execution.allow = {cfg.execution.allow}).",
|
|
fg=typer.colors.RED, err=True,
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
def promoted_tables_for(cfg, session_id: str | None) -> set[str] | None:
|
|
if session_id is None:
|
|
return None
|
|
from tht.cli.session_cmd import load_snapshot_or_exit
|
|
|
|
raw = load_snapshot_or_exit(cfg, session_id).artifacts.get("schema_linking")
|
|
if raw is None:
|
|
return None
|
|
from tht.session.models import SchemaLinking
|
|
|
|
linking = SchemaLinking.model_validate(json.loads(raw))
|
|
return {
|
|
c.name for c in linking.candidates
|
|
if c.kind == "table" and c.decision == "promoted"
|
|
}
|
|
|
|
|
|
# Staged for a future SQL-projection consumer; hard column enforcement is a
|
|
# documented non-goal (Option-1 soft) — not yet wired into validate_or_exit.
|
|
def promoted_columns_for(cfg, session_id: str | None) -> set[str] | None:
|
|
if session_id is None:
|
|
return None
|
|
from tht.cli.session_cmd import load_snapshot_or_exit
|
|
|
|
raw = load_snapshot_or_exit(cfg, session_id).artifacts.get("schema_linking")
|
|
if raw is None:
|
|
return None
|
|
from tht.session.models import SchemaLinking
|
|
|
|
linking = SchemaLinking.model_validate(json.loads(raw))
|
|
return {
|
|
c.name for c in linking.candidates
|
|
if c.kind == "column" and c.decision == "promoted"
|
|
}
|
|
|
|
|
|
def validate_or_exit(cfg, sql: str, session_id: str | None):
|
|
"""Validazione statica; stampa errori/warning. Exit 1 sugli errori."""
|
|
from tht.sqlcheck import validate_sql
|
|
|
|
result = validate_sql(
|
|
sql,
|
|
physical=_load_physical_or_exit(cfg),
|
|
promoted_tables=promoted_tables_for(cfg, session_id),
|
|
forbidden_functions=set(cfg.execution.forbidden_functions),
|
|
)
|
|
for w in result.warnings:
|
|
typer.secho(f" warning: {w}", fg=typer.colors.YELLOW)
|
|
if not result.ok:
|
|
for e in result.errors:
|
|
typer.secho(f" ERRORE: {e}", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
return result
|
|
|
|
|
|
def do_explain(cfg, sql: str):
|
|
"""EXPLAIN through the configured DWH adapter."""
|
|
from tht.adapters.factory import build_dwh
|
|
|
|
return build_dwh(cfg).explain(sql)
|
|
|
|
|
|
def _run_transport(cfg, sql: str, *, limit: int):
|
|
"""Dispatch through the configured DWH adapter."""
|
|
from tht.adapters.factory import build_dwh
|
|
|
|
return build_dwh(cfg).run_query(sql, limit=limit)
|
|
|
|
|
|
def do_run(cfg, sql: str, *, limit: int, offset: int = 0):
|
|
"""Esecuzione controllata secondo il transport configurato (direct|rest).
|
|
|
|
Per offset == 0: path invariato (LIMIT iniettato dall'esecutore via AST, +1 per
|
|
rilevare il troncamento).
|
|
|
|
Per offset > 0: la query viene wrappata in `SELECT * FROM (...) LIMIT (N+1) OFFSET M`.
|
|
Il +1 e' essenziale: l'esecutore vede gia' un LIMIT esterno, quindi il suo
|
|
`_inject_limit` non inietta nulla (bail perche' un LIMIT E' PRESENTE, non assente) e
|
|
non rileverebbe mai il troncamento. Recuperando N+1 righe qui calcoliamo noi
|
|
`truncated = len(rows) > N` e ritagliamo a N. NON rimuovere il LIMIT del wrapper
|
|
pensando sia ridondante: e' l'unico cap effettivo per il path con offset.
|
|
"""
|
|
if offset == 0:
|
|
return _run_transport(cfg, sql, limit=limit)
|
|
|
|
from dataclasses import replace
|
|
|
|
from tht.execute.limit import inject_limit_offset
|
|
|
|
probe_limit = limit + 1
|
|
wrapped = inject_limit_offset(sql, limit=probe_limit, offset=offset)
|
|
# limit=probe_limit cosi' l'esecutore ritaglia a N+1 (non a N) e ci lascia la riga sonda.
|
|
result = _run_transport(cfg, wrapped, limit=probe_limit)
|
|
truncated = len(result.rows) > limit
|
|
return replace(result, rows=result.rows[:limit], truncated=truncated)
|
|
|
|
|
|
@sql_app.command("validate")
|
|
def validate_cmd(
|
|
file: Path = typer.Argument(..., help="File SQL da validare."),
|
|
session: str = typer.Option(None, "--session", help="Verifica anche il perimetro promosso."),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""Parse, read-only strutturale, blacklist funzioni, oggetti vs mschema."""
|
|
cfg = _load_config_or_exit(config)
|
|
validate_or_exit(cfg, _read_sql(file), session)
|
|
typer.secho("OK: SQL valido (statico).", fg=typer.colors.GREEN)
|
|
|
|
|
|
@sql_app.command("explain")
|
|
def explain_cmd(
|
|
file: Path = typer.Argument(...),
|
|
session: str = typer.Option(None, "--session"),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""EXPLAIN (FORMAT JSON) con sintesi e warning dal piano. Mai ANALYZE."""
|
|
from tht.execute import ExecutionError
|
|
from tht.execute.warnings import plan_warnings
|
|
|
|
cfg = _load_config_or_exit(config)
|
|
require_action(cfg, "explain")
|
|
sql = _read_sql(file)
|
|
validate_or_exit(cfg, sql, session)
|
|
try:
|
|
plan = do_explain(cfg, sql)
|
|
except ExecutionError as e:
|
|
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
typer.echo(f"costo totale stimato: {plan.total_cost}")
|
|
typer.echo(f"righe stimate: {plan.plan_rows}")
|
|
typer.echo(f"nodi del piano: {', '.join(plan.node_types)}")
|
|
for w in plan_warnings(plan, cfg.execution):
|
|
typer.secho(f" warning: {w}", fg=typer.colors.YELLOW)
|
|
|
|
|
|
@sql_app.command("preview")
|
|
def preview_cmd(
|
|
file: Path = typer.Argument(None, help="File SQL da eseguire. Opzionale se --session è dato."),
|
|
limit: int = typer.Option(None, "--limit", help="Default: execution.max_preview_rows."),
|
|
offset: int = typer.Option(0, "--offset", help="Riga di partenza (0-based) per il paging."),
|
|
session: str = typer.Option(None, "--session"),
|
|
json_out: bool = typer.Option(False, "--json", help="Output JSON puro per il backend (sopprime tabella rich)."),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""Esecuzione controllata con LIMIT iniettato; aggregati mostrati per interi.
|
|
|
|
Se FILE è omesso e --session è fornito, il file viene risolto automaticamente
|
|
dall'artefatto `sql_final` del repository della sessione.
|
|
"""
|
|
from tht.execute import ExecutionError
|
|
|
|
cfg = _load_config_or_exit(config)
|
|
require_action(cfg, "preview")
|
|
if file is None:
|
|
if session is None:
|
|
typer.secho(
|
|
"ERRORE: specificare FILE oppure --session.",
|
|
fg=typer.colors.RED, err=True,
|
|
)
|
|
raise typer.Exit(code=1)
|
|
sql = _session_sql(cfg, session)
|
|
else:
|
|
sql = _read_sql(file)
|
|
check = validate_or_exit(cfg, sql, session)
|
|
effective_limit = limit if limit is not None else cfg.execution.max_preview_rows
|
|
try:
|
|
result = do_run(cfg, sql, limit=effective_limit, offset=offset)
|
|
except ExecutionError as e:
|
|
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
|
|
if json_out:
|
|
typer.echo(json.dumps(
|
|
{
|
|
"columns": result.columns,
|
|
"rows": [list(r) for r in result.rows],
|
|
"execution_ms": result.execution_ms,
|
|
"truncated": result.truncated,
|
|
"limit": effective_limit,
|
|
"offset": offset,
|
|
},
|
|
ensure_ascii=False,
|
|
))
|
|
return
|
|
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from tht.execute.warnings import runtime_warnings, static_warnings
|
|
|
|
cells = len(result.rows) * len(result.columns)
|
|
is_aggregate = (
|
|
"aggregate" in cfg.execution.allow
|
|
and not result.truncated
|
|
and cells <= cfg.execution.max_aggregate_cells
|
|
)
|
|
title = "Risultato aggregato" if is_aggregate else f"Preview (limit {effective_limit})"
|
|
# titolo come riga di testo (non come title della tabella rich, che verrebbe
|
|
# spezzato sulla larghezza ridotta della tabella per query strette)
|
|
typer.echo(f"{title} — {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 static_warnings(check.ast) + runtime_warnings(result, cfg.execution):
|
|
typer.secho(f" warning: {w}", fg=typer.colors.YELLOW)
|
|
|
|
|
|
def _session_sql(cfg, session_id: str) -> str:
|
|
from tht.cli.session_cmd import load_snapshot_or_exit
|
|
|
|
sql = load_snapshot_or_exit(cfg, session_id).artifacts.get("sql_final")
|
|
if sql is None:
|
|
typer.secho("ERRORE: sql_final.sql non trovato.", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
return sql
|
|
|
|
|
|
@sql_app.command("set-final")
|
|
def set_final_cmd(
|
|
session: str = typer.Option(..., "--session"),
|
|
file: str = typer.Option(..., "--file", help="File SQL, oppure '-' per stdin."),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""Persist clean final SQL through the configured session repository."""
|
|
import sys
|
|
|
|
from tht.cli.session_cmd import load_session_or_exit, session_repository
|
|
|
|
cfg = _load_config_or_exit(config)
|
|
load_session_or_exit(cfg, session)
|
|
sql = sys.stdin.read() if file == "-" else _read_sql(Path(file))
|
|
session_repository(cfg).write_artifact(session, "sql_final", sql)
|
|
typer.secho("OK: SQL finale salvato.", fg=typer.colors.GREEN)
|
|
|
|
|
|
@sql_app.command("save")
|
|
def save_cmd(
|
|
dest: Path = typer.Argument(..., help="Percorso di destinazione del file SQL."),
|
|
session: str = typer.Option(..., "--session"),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""Salva una copia di sql_final.sql nel percorso indicato (su richiesta esplicita)."""
|
|
cfg = _load_config_or_exit(config)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(_session_sql(cfg, session))
|
|
typer.secho(f"OK: SQL salvato in {dest}", fg=typer.colors.GREEN)
|
|
|
|
|
|
@sql_app.command("export")
|
|
def export_cmd(
|
|
dest: Path = typer.Argument(..., help="Percorso del CSV di destinazione."),
|
|
session: str = typer.Option(..., "--session"),
|
|
config: Path = CONFIG_OPT,
|
|
) -> None:
|
|
"""Esegue sql_final.sql nelle 4 reti e scrive i risultati in CSV
|
|
(cap: execution.max_export_rows)."""
|
|
import csv as csv_mod
|
|
|
|
from tht.execute import ExecutionError
|
|
|
|
cfg = _load_config_or_exit(config)
|
|
require_action(cfg, "export")
|
|
sql = _session_sql(cfg, session)
|
|
validate_or_exit(cfg, sql, session)
|
|
try:
|
|
result = do_run(cfg, sql, limit=cfg.execution.max_export_rows)
|
|
except ExecutionError as e:
|
|
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
|
raise typer.Exit(code=1)
|
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
with dest.open("w", newline="") as f:
|
|
writer = csv_mod.writer(f)
|
|
writer.writerow(result.columns)
|
|
writer.writerows(result.rows)
|
|
typer.secho(f"OK: {len(result.rows)} righe esportate in {dest}", fg=typer.colors.GREEN)
|
|
if result.truncated:
|
|
typer.secho(
|
|
f" warning: risultato troncato al cap di {cfg.execution.max_export_rows} righe "
|
|
f"(execution.max_export_rows)", fg=typer.colors.YELLOW,
|
|
)
|