Files
fn_registry/apps/auto_metabase/sync_validate.py
T
egutierrez 58fab5ad34 feat(auto_metabase): push-all + describe/sql + auto-inject de dashcards
- push_all(): pushea todos los YAMLs de un proyecto (cards primero,
  dashboards despues), solo CREATE/UPDATE, resiliente a fallos por item
- explore.py: comandos describe (schema de DB) y sql (query ad-hoc con
  limite, cap 5MB, bloqueo de escrituras destructivas)
- payload.py: auto-inyecta id:-N, visualization_settings:{} y
  parameter_mappings:[] en dashcards nuevas para evitar 500 en push
- test_local: 11 cards + 3 dashboards sobre Sample Database de Metabase
- registry.db regenerado con auto_metabase_py_analytics indexada

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:14:05 +02:00

177 lines
5.8 KiB
Python

"""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()}")