feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
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.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user