"""Esecuzione controllata e EXPLAIN via REST (rpc `run_query`/`explain_query`). Produce gli stessi tipi del transport diretto (`ExecResult`, `PlanSummary`) e traduce `RestError` in `ExecutionError`, così le fasi 6/7 e la gestione errori della CLI non cambiano. Differenze rispetto al diretto: read-only e statement_timeout sono garantiti lato server; il client mantiene solo l'iniezione del LIMIT (per il rilevamento del troncamento in preview). """ import time from tht.execute import ExecResult, ExecutionError, PlanSummary, _inject_limit, assert_read_only from tht.rest.client import RestError from tht.rest.explain import parse_text_plan def run_controlled_rest(client, sql: str, *, limit: int) -> ExecResult: # Guard read-only client-side anche sul path REST (D7): non delegare l'unica verifica # al server. Stesso check strutturale del path diretto. assert_read_only(sql) final_sql, injected = _inject_limit(sql, limit) start = time.monotonic() try: rows_dicts = client.run_query(final_sql) except RestError as e: raise ExecutionError(str(e)) from e elapsed_ms = int((time.monotonic() - start) * 1000) if not isinstance(rows_dicts, list): # run_query atteso come lista di righe; una risposta inattesa (dict d'errore, # scalare) non deve crashare con KeyError/TypeError opaco. raise ExecutionError( f"Risposta REST inattesa da run_query (atteso elenco di righe): {type(rows_dicts).__name__}" ) columns = list(rows_dicts[0].keys()) if rows_dicts else [] rows = [tuple(r.get(c) for c in columns) for r in rows_dicts] truncated = injected and len(rows) > limit return ExecResult( columns=columns, rows=rows[:limit], execution_ms=elapsed_ms, truncated=truncated ) def explain_rest(client, sql: str) -> PlanSummary: assert_read_only(sql) try: lines = client.explain_query(sql) except RestError as e: raise ExecutionError(str(e)) from e return parse_text_plan(lines)