mcpField
P
powerbi-to-sl

powerbi-to-sl

github.com/keboolaData & databases17173 installsUpdated 1d ago
Repository

About powerbi-to-sl

powerbi-to-sl migrates existing Power BI artifacts into Keboola Semantic Layer JSON payloads. It suits teams moving established models, translating structure mechanically while preserving DAX for review before an optional, confirmed push.

Key features

  1. 01Detects TMDL and per-table JSON input
  2. 02Maps tables, measures, and relationships
  3. 03Preserves DAX expressions and reports warnings
  4. 04Validates output against JSON schemas
  5. 05Supports confirmed push or sl-toolkit handoff

Use cases

  1. 01Migrating a Power BI model to Keboola SL
  2. 02Reviewing DAX measures before SQL rewriting
  3. 03Preparing and validating semantic-layer payloads

MCP support

The skill can use Microsoft's Power BI Modeling MCP in read-only mode to produce TMDL when needed. It can also hand off publishing to sl-toolkit.

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/keboola/ai-kit --skill powerbi-to-sl

After installing, type/powerbi-to-slin Claude Code to confirm it loaded.

Details

From the project README

powerbi-to-sl — Power BI → Keboola Semantic Layer Migration

Read existing PowerBI artifacts → map to Keboola SL payloads → write JSON files → user confirms → push via sl-toolkit or direct REST. Companion to sl-toolkit (greenfield); this is the brownfield path.


Step 0 — Identify input

Ask the user where the PowerBI artifacts are, then auto-detect format:

INPUT_DIR="$(read -p 'Path to PowerBI artifacts: ' x; echo "$x")"
python3 -c "
import os, sys
p = sys.argv[1]
tmdl = [f for f in os.listdir(p) if f.endswith('.tmdl')] if os.path.isdir(p) else []
jsons = [f for f in os.listdir(p) if f.endswith('_semantic_layer.json')] if os.path.isdir(p) else []
if tmdl: print('tmdl')
elif jsons: print('per-table-json')
else: print('unknown')
" "$INPUT_DIR"

If unknown, ask the user to clarify, or have them run Microsoft's Power BI Modeling MCP with --readonly against their dataset to produce TMDL first.


Step 1 — Business questions (before mapping)

"Three quick questions:

  1. What is the Keboola bucket prefix for these tables? (e.g., in.c-pbi-migration — used to build tableId for each semantic-dataset)
  2. Semantic model name + display name? (e.g., acme-pbi-migration / "Acme Power BI Migration")
  3. Snowflake or BigQuery dialect? (default: Snowflake)"

Persist answers as BUCKET_PREFIX, MODEL_NAME, MODEL_DISPLAY, DIALECT.


Step 2 — Run the migrator

python3 scripts/migrate.py \
  --input "$INPUT_DIR" \
  --input-format "$FORMAT" \
  --bucket-prefix "$BUCKET_PREFIX" \
  --model-name "$MODEL_NAME" \
  --model-display "$MODEL_DISPLAY" \
  --dialect "$DIALECT" \
  --output ./out/

Output layout:

out/
  semantic-model.json
  semantic-dataset/
    <bucket-prefix>.<table>.json   # one per PowerBI table
  semantic-metric/
    <measure-slug>.json            # one per PowerBI measure
  semantic-relationship/
    <from>__<to>.json              # one per PowerBI relationship
  WARNINGS.md                      # DAX-not-translated, unmapped types, etc.

Step 3 — Review WARNINGS.md with the user

The migrator preserves DAX measure expressions verbatim in semantic-metric.sql. Show the user WARNINGS.md and ask whether to:

  • Punt — leave DAX in place; downstream Kai (or a human) rewrites as SQL on-demand.
  • Auto-translate trivial cases — SUM('Table'[Col]) → SUM("Table"."Col"), COUNT, DISTINCTCOUNT, AVERAGE. Anything with CALCULATE, filter context, RELATED, time-intelligence, or variables stays flagged.
  • Human pass — open the WARNINGS list and rewrite line by line.

Default: punt + show the list. Trivial-case translation is opt-in.


Step 4 — Validate against Keboola SL schemas (jsonschema)

python3 -c "
import json, jsonschema, glob, sys, os
schemas_dir = os.environ['SCHEMAS_DIR']
schemas = {
    name: json.load(open(f'{schemas_dir}/semantic-{name}_schema_1.0.0.json'))
    for name in ['model','dataset','metric','relationship']
}
errs = 0
for kind, schema in schemas.items():
    pattern = './out/semantic-model.json' if kind == 'model' else f'./out/semantic-{kind}/*.json'
    for f in glob.glob(pattern):
        try:
            jsonschema.validate(json.load(open(f)), schema)
        except jsonschema.ValidationError as e:
            errs += 1
            print(f'❌ {f}: {e.message}')
print(f'{errs} validation errors' if errs else '✅ all valid')
"

The same validation runs as a pytest assertion in tests/test_smoke.py. Set SCHEMAS_DIR to the directory containing the Keboola metastore semantic-* JSON schemas.


Step 5 — Push (optional, gated on user confirmation)

Confirm with the user before any write. Two paths:

Path A — via sl-toolkit (preferred if that plugin is installed; it owns the canonical generate–validate–push pipeline):

"Hand off to sl-toolkit? It owns the canonical push pipeline for any SL model. Or push directly to the metastore REST API?"

If sl-toolkit chosen, invoke its push step with --source ./out/.

Path B — direct REST (when sl-toolkit is not available):

import json, os, urllib.request, glob
from concurrent.futures import ThreadPoolExecutor
TOKEN = os.environ['KBC_STORAGE_TOKEN']
METASTORE = os.environ['KBC_METASTORE_URL']  # e.g. https://metastore.us-east4.gcp.keboola.com

def post(kind, payload):
    req = urllib.request.Request(
        f'{METASTORE}/api/v1/repository/semantic-{kind}',
        data=json.dumps(payload).encode(),
        headers={'X-StorageAPI-Token': TOKEN, 'Content-Type': 'application/json'},
        method='POST')
    return urllib.request.urlopen(req, timeout=30).read()

# Model first — get the UUID, propagate to children
model_uuid = json.loads(post('model', json.load(open('./out/semantic-model.json'))))['data']['id']
for kind in ['dataset','metric','relationship']:
    payloads = []
    for f in glob.glob(f'./out/semantic-{kind}/*.json'):
        p = json.load(open(f))
        p['modelUUID'] = model_uuid
        payloads.append(p)
    with ThreadPoolExecutor(max_workers=10) as ex:
        list(ex.map(lambda p: post(kind, p), payloads))

Mapping reference

Power BIKeboola SL
tables[].namesemantic-dataset.name + tableId
tables[].descriptionsemantic-dataset.description
tables[].columns[].namesemantic-dataset.fields[].name
tables[].columns[].dataTypesemantic-dataset.fields[].type *
tables[].columns[].descriptionsemantic-dataset.fields[].description
PK column hintssemantic-dataset.primaryKey
tables[].measures[].expression (DAX)semantic-metric.sql (verbatim)
tables[].measures[].descriptionsemantic-metric.description
relationships[]semantic-relationship

Type mapping: string→string, int64/integer→integer, double/decimal/currency→decimal, dateTime/date→datetime/date, boolean→boolean. Unknown types fall back to string and get logged.


Known gotchas

  • Per-table JSON loses Power Query M steps. The older get_semantic_model output does not capture M expressions. If full fidelity is required, re-extract with Microsoft's Power BI Modeling MCP --readonly flag to produce TMDL, which captures DAX and M natively.
  • DAX ≠ SQL. Verbatim preservation is the default; downstream Kai or a human translates. CALCULATE, RELATED, time-intelligence, and variables do not survive trivial translation.
  • PowerBI table names contain spaces and non-ASCII characters. Slugify for filenames; keep originals in name/displayName. UTF-8 paths are fine on macOS/Linux but watch for filesystem-quirky environments.
  • PK detection is best-effort. Power BI doesn't always mark PKs explicitly; the migrator uses (a) explicit isKey markers in TMDL, (b) relationship to columns as a heuristic, (c) falls back to empty primaryKey + a WARNINGS entry asking the user to pick.
  • Relationship cardinality. manyToOne and oneToOne → inner; oneToMany → left from the "many" side. manyToMany produces a WARNINGS entry — the SL model expects star-schema friendliness.

Why two skills exist (powerbi-to-sl vs. sl-toolkit)

Aspectsl-toolkitpowerbi-to-sl (this)
DirectionGreenfield (Keboola tables → SL)Brownfield (Power BI → SL)
InputKeboola storage + SQL transformationsTMDL folder or per-table JSON
GenerationLLM-driven from schema + KPIsMechanical structural translation
ValidationAuto-fix + LLM critiquejsonschema validate + WARNINGS
PushOwns push pipelineHands off to sl-toolkit push

When both are installed, this skill produces SL payloads and delegates push to sl-toolkit. Reuse, not overlap.

Comments

Markdown supported