"""Validate: lee un YAML local, construye el payload, valida estructura y SQL. Read-only — nunca escribe nada en Metabase ni en disco. Es la red de seguridad antes de `push`. Tres niveles de validacion (todos se ejecutan, recolectando issues): 1. Carga del YAML y consistencia de _meta vs args + _meta.id vs index. (R9 + R11 — abortan si fallan, son corruption checks). 2. Resolucion de _refs: todos los slugs deben existir en index. (R10 — aborta). 3. Estructura del payload: usa metabase_validate_card_payload / _dashboard_payload del registry. Reporta issues como warnings, no aborta. 4. SQL dry-run (solo cards native, opcional con --check-sql). Usa metabase_validate_sql. Aborta de la lista de issues si SQL falla. """ from __future__ import annotations from pathlib import Path from metabase.validation import ( metabase_validate_card_payload, metabase_validate_dashboard_payload, metabase_validate_sql, ) from payload import ( assert_id_matches_index, assert_meta, build_payload, item_path, known_card_ids, load_item_yaml, ) # Codigo de salida: 0 = OK, 1 = warnings, 2 = errores fatales (R9/R10/R11) class ValidationResult: def __init__(self): self.errors: list[str] = [] # fatales (corruption, refs rotas) self.warnings: list[str] = [] # estructurales (validators puros) self.sql_status: str | None = None # "ok" / "failed" / "skipped" self.payload: dict | None = None @property def ok(self) -> bool: return not self.errors and not self.warnings and self.sql_status != "failed" def exit_code(self) -> int: if self.errors or self.sql_status == "failed": return 2 if self.warnings: return 1 return 0 def _extract_native_sql(payload: dict) -> str | None: """Extrae SQL de un payload de card si es native query. Soporta legacy y MBQL5.""" dq = payload.get("dataset_query") if not isinstance(dq, dict): return None # Legacy: dq.native.query native = dq.get("native") if isinstance(native, dict) and isinstance(native.get("query"), str): return native["query"] # MBQL5: dq.stages[0].native (string directo) stages = dq.get("stages") if isinstance(stages, list) and stages: first = stages[0] if isinstance(first, dict): n = first.get("native") if isinstance(n, str): return n return None def validate_one( project, kind: str, slug: str, *, check_sql: bool = False, client=None, ) -> ValidationResult: """Punto de entrada. `project` es main.Project.""" result = ValidationResult() # ---- Capa 1: carga + meta consistency path = item_path(project.dir, kind, slug) if not path.exists(): result.errors.append(f"YAML no existe: {path.relative_to(project.dir.parent.parent)}") return result try: doc = load_item_yaml(path) except ValueError as e: result.errors.append(str(e)) return result index = project.load_index() try: assert_meta(doc, kind, slug, path) except ValueError as e: result.errors.append(f"R9 violado: {e}") try: assert_id_matches_index(doc, kind, slug, index, path) except ValueError as e: result.errors.append(f"R11 violado: {e}") if result.errors: return result # corruption — no seguir # ---- Capa 2: build payload (resuelve refs) try: payload = build_payload(kind, doc, index, env=project.load_env()) except ValueError as e: result.errors.append(f"R10 violado: {e}") return result result.payload = payload # ---- Capa 3: validacion estructural (puras del registry) if kind == "card": result.warnings.extend(metabase_validate_card_payload(payload)) elif kind == "dashboard": result.warnings.extend( metabase_validate_dashboard_payload(payload, known_card_ids(index)) ) # databases/collections: no tienen validators todavia (pocos campos, bajo riesgo) # ---- Capa 4: SQL dry-run (opcional, solo cards native) if check_sql and kind == "card": sql = _extract_native_sql(payload) if sql is None: result.sql_status = "skipped" else: if client is None: result.warnings.append("--check-sql pedido pero client no inicializado") result.sql_status = "skipped" else: db_id = payload.get("database_id") if db_id is None: result.warnings.append("no se puede check-sql: payload sin database_id") result.sql_status = "skipped" else: sql_result = metabase_validate_sql(client, db_id, sql) if sql_result["ok"]: result.sql_status = "ok" else: result.sql_status = "failed" result.errors.append(f"SQL invalido: {sql_result['error']}") return result def print_result(kind: str, slug: str, result: ValidationResult) -> None: """Imprime el resultado de la validacion en formato humano.""" print(f"validate {kind} {slug}") if result.errors: print(f" ERRORS ({len(result.errors)}):") for e in result.errors: print(f" ✗ {e}") if result.warnings: print(f" WARNINGS ({len(result.warnings)}):") for w in result.warnings: print(f" ! {w}") if result.sql_status: marker = {"ok": "✓", "failed": "✗", "skipped": "-"}[result.sql_status] print(f" SQL: {marker} {result.sql_status}") if not result.errors and not result.warnings: print(" ✓ payload valido") print(f" exit_code: {result.exit_code()}")