4300f1242d
Añade un conjunto amplio de funciones al paquete python/functions/metabase: - Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test). - Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones. - Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio. - .md por funcion con frontmatter para que fn index los registre. - Actualiza pyproject.toml y uv.lock con las dependencias resultantes. Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
276 lines
9.9 KiB
Python
276 lines
9.9 KiB
Python
"""Tests para metabase_mbql_validate."""
|
|
|
|
import sys
|
|
import os
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from metabase.metabase_mbql_validate import metabase_mbql_validate
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DQ valido: estructura simplificada basada en card 5705 post-fix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_DQ = {
|
|
"lib/type": "mbql/query",
|
|
"database": 6,
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"source-card": 100,
|
|
"aggregation": [
|
|
[
|
|
"sum",
|
|
{"lib/uuid": "agg-uuid-1"},
|
|
[
|
|
"field",
|
|
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-1"},
|
|
"Cantidad",
|
|
],
|
|
]
|
|
],
|
|
"expressions": [
|
|
[
|
|
"-",
|
|
{
|
|
"lib/uuid": "expr-uuid-1",
|
|
"lib/expression-name": "Diferencia_Cantidad",
|
|
},
|
|
[
|
|
"field",
|
|
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-2"},
|
|
"Valor_A",
|
|
],
|
|
[
|
|
"field",
|
|
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-3"},
|
|
"Valor_B",
|
|
],
|
|
]
|
|
],
|
|
},
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"expressions": [
|
|
[
|
|
"case",
|
|
{
|
|
"lib/uuid": "case-uuid-1",
|
|
"lib/expression-name": "Valor medio de venta",
|
|
},
|
|
[
|
|
[
|
|
[
|
|
"=",
|
|
{"lib/uuid": "cond-uuid-1"},
|
|
[
|
|
"field",
|
|
{
|
|
"base-type": "type/Integer",
|
|
"lib/uuid": "field-uuid-4",
|
|
},
|
|
"Tickets",
|
|
],
|
|
0,
|
|
],
|
|
0,
|
|
],
|
|
[
|
|
[
|
|
"!=",
|
|
{"lib/uuid": "cond-uuid-2"},
|
|
[
|
|
"field",
|
|
{
|
|
"base-type": "type/Integer",
|
|
"lib/uuid": "field-uuid-5",
|
|
},
|
|
"Tickets",
|
|
],
|
|
0,
|
|
],
|
|
[
|
|
"/",
|
|
{"lib/uuid": "div-uuid-1"},
|
|
[
|
|
"field",
|
|
{
|
|
"base-type": "type/Decimal",
|
|
"lib/uuid": "field-uuid-6",
|
|
},
|
|
"Valor_vendido",
|
|
],
|
|
[
|
|
"field",
|
|
{
|
|
"base-type": "type/Integer",
|
|
"lib/uuid": "field-uuid-7",
|
|
},
|
|
"Tickets",
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def test_valid_dq_returns_no_errors():
|
|
"""DQ valido retorna lista vacia."""
|
|
errors = metabase_mbql_validate(VALID_DQ)
|
|
assert errors == [], f"Esperaba 0 errores, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check 1: UUID duplicado
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_duplicate_uuid_detected():
|
|
"""UUID repetido en el arbol MBQL genera error."""
|
|
dq = {
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"expressions": [
|
|
[
|
|
"-",
|
|
{"lib/uuid": "dup-uuid-001", "lib/expression-name": "ExprA"},
|
|
["field", {"base-type": "type/Decimal", "lib/uuid": "dup-uuid-001"}, "CampoX"],
|
|
["field", {"base-type": "type/Decimal", "lib/uuid": "unique-uuid-002"}, "CampoY"],
|
|
]
|
|
],
|
|
}
|
|
]
|
|
}
|
|
errors = metabase_mbql_validate(dq)
|
|
assert any("dup-uuid-001" in e for e in errors), f"Esperaba error de UUID duplicado, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check 2: Stage mixing — expressions referencian slots de aggregation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_stage_mixing_detected():
|
|
"""Stage con aggregations y expressions que referencian slots generados."""
|
|
dq = {
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"aggregation": [
|
|
["sum", {"lib/uuid": "agg1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "Precio"]]
|
|
],
|
|
"expressions": [
|
|
[
|
|
"*",
|
|
{"lib/uuid": "expr2", "lib/expression-name": "DobleSum"},
|
|
# field sin base-type y nombre 'sum' = slot ref de aggregation
|
|
["field", {"lib/uuid": "ref-slot"}, "sum"],
|
|
2,
|
|
]
|
|
],
|
|
}
|
|
]
|
|
}
|
|
errors = metabase_mbql_validate(dq)
|
|
assert any("mezcla" in e.lower() or "mixing" in e.lower() or "stage" in e.lower() for e in errors), \
|
|
f"Esperaba error de stage mixing, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check 3: Slot ref rota — sum_99 inexistente
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_broken_slot_ref_detected():
|
|
"""Expression que referencia sum_99 cuando solo hay 1 sum genera error."""
|
|
dq = {
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"aggregation": [
|
|
["sum", {"lib/uuid": "agg-s1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f-s1"}, "Precio"]]
|
|
],
|
|
"expressions": [
|
|
[
|
|
"+",
|
|
{"lib/uuid": "expr-broken", "lib/expression-name": "SumRota"},
|
|
# sum_99 no existe (solo hay 1 sum = sum_0)
|
|
["field", {"lib/uuid": "ref-broken"}, "sum_99"],
|
|
1,
|
|
]
|
|
],
|
|
}
|
|
]
|
|
}
|
|
errors = metabase_mbql_validate(dq)
|
|
assert any("sum_99" in e for e in errors), f"Esperaba error de slot ref roto, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check 4: Case con estructura invalida
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_case_malformed_cases_not_pairs():
|
|
"""Case con casos que no son pares [cond, result] genera error."""
|
|
dq = {
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"expressions": [
|
|
[
|
|
"case",
|
|
{"lib/uuid": "case-bad", "lib/expression-name": "CaseMalo"},
|
|
# lista con un solo elemento (no par)
|
|
[["solo_elemento"]],
|
|
]
|
|
],
|
|
}
|
|
]
|
|
}
|
|
errors = metabase_mbql_validate(dq)
|
|
assert any("case" in e.lower() for e in errors), f"Esperaba error de case structure, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check 5: Name collision en expressions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_name_collision_detected():
|
|
"""Dos expressions con mismo lib/expression-name en la misma stage generan error."""
|
|
dq = {
|
|
"stages": [
|
|
{
|
|
"lib/type": "mbql.stage/mbql",
|
|
"expressions": [
|
|
["-", {"lib/uuid": "e1", "lib/expression-name": "MiCalculo"},
|
|
["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "A"],
|
|
["field", {"base-type": "type/Decimal", "lib/uuid": "f2"}, "B"]],
|
|
["+", {"lib/uuid": "e2", "lib/expression-name": "MiCalculo"},
|
|
["field", {"base-type": "type/Decimal", "lib/uuid": "f3"}, "C"],
|
|
1],
|
|
],
|
|
}
|
|
]
|
|
}
|
|
errors = metabase_mbql_validate(dq)
|
|
assert any("MiCalculo" in e for e in errors), f"Esperaba error de name collision, got: {errors}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check: stages ausente
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_missing_stages_returns_error():
|
|
"""dataset_query sin 'stages' devuelve error de estructura."""
|
|
errors = metabase_mbql_validate({"database": 1})
|
|
assert any("stages" in e.lower() for e in errors), f"Esperaba error de stages ausente, got: {errors}"
|