
Connect Pbid
- 40 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Connect to Power BI Desktop's local Analysis Services instance via PowerShell and TOM/ADOMD.NET to enumerate models, run DAX queries, and modify metadata.
About
Provides TOM and ADOMD.NET PowerShell guidance to connect to Power BI Desktop's local Analysis Services port, enumerate the model, run and trace DAX, and modify measures, relationships, and roles. A developer uses it to query and edit an open PBI Desktop model and reload or screenshot the report canvas via the Desktop Bridge.
- Enumerates ports and connects via TOM to the local model
- Adds measures, relationships, RLS roles, and validates DAX before applying
Connect Pbid by the numbers
- 40 all-time installs (skills.sh)
- Ranked #1,000 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/data-goblin/power-bi-agentic-development --skill connect-pbidAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 40 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Connect to Power BI Desktop's local Analysis Services instance via PowerShell and TOM/ADOMD.NET to enumerate models, run DAX queries, and modify metadata.
Files
Connect to Power BI Desktop (Local Analysis Services)
CRITICAL: Record mistakes, surprises, and model-specific nuances encountered while using this skill in .claude/rules/connect-pbid.md. This file must begin with "Learnings from Claude about connecting to semantic models via the connect-pbid skill". Write only active reference notes (e.g. "QueryGroup property returns an object; access .Folder for the name string"); do not log a changelog or history of events. Omit anything already documented in the skill or its references. Keep the file under 1500 characters at all times; prune stale entries when adding new ones. Do not over-attend to this file; update it only when something genuinely unexpected is discovered.Note: No MCP server required; do not use this skill with MCP servers or CLI tools. Use this skill to execute PowerShell commands directly via Bash to connect to Power BI Desktop's local Analysis Services instance.
Expert guidance for connecting to Power BI Desktop's local tabular model via the Tabular Object Model (TOM) and ADOMD.NET in PowerShell. Covers connection, enumeration, DAX queries, query traces, and full model modification.
When to Use This Skill
Activate only when the Tabular Editor CLI or a Power BI MCP server is unavailable. TOM is more reliable than direct TMDL editing because it validates changes against the engine and applies them atomically.
WARNING: This skill does NOT support remote models via the XMLA endpoint. For Direct Lake models or models hosted in Fabric, use the Tabular Editor CLI or a Power BI MCP server instead; the local Analysis Services proxy does not expose Direct Lake databases to external TOM/ADOMD.NET connections.
Model and report: routing
Power BI Desktop exposes the model and the report as two separate local surfaces. This skill owns the model surface and report-canvas verification, and routes report authoring to the right skill:
- Model (tables, columns, measures, relationships, roles, calculation groups, refresh): this skill, via TOM/ADOMD over the local Analysis Services instance. For model edits, prefer the
teCLI or a model MCP when available; fall back to this skill's TOM when they are not (see "When to Use This Skill"). - Report-canvas verification (reload after edits, screenshot pages): this skill, the raw Desktop Bridge named-pipe API (section 13).
- Report authoring (visuals, pages, formatting, filters, bookmarks, themes): the
pbir-cliskill in the reports plugin (it drives thepbirCLI). The Desktop Bridge here only reloads and screenshots; it never edits visuals. Route every visual or page change topbir-cli. - Report JSON edited directly (only when
pbiris unavailable): thepbir-formatskill in the pbip plugin.
Full loop on an open PBIP: change the model with TOM here, change visuals with pbir-cli, then reload and screenshot with the Desktop Bridge here to verify, and iterate.
Critical
- Power BI Desktop must be open with a model loaded before connecting; if there are errors it is likely due to a "thin report" connected to a remote model, or a Direct Lake model (which uses a remote proxy that blocks external connections)
- The local Analysis Services instance only accepts connections from
localhost - Multiple PBI Desktop files open means multiple
msmdsrv.exeprocesses on different ports. Connect to each port, read$server.Databases[0].Name, and ask the user which model to work with if more than one is found. When thepbirCLI is installed, preferpbir desktop listto map each Desktop PID to the exact file it has open (see Section 2a) - A workspace engine reporting
Databases: 0belongs to a thin report (live connection to a remote model); there is no local model to connect to. Query thin reports through their remote model instead (pbir model -qroutes there automatically) - Always use a timeout of 60000ms or higher for PowerShell commands via Bash
- Shell escaping: Bash eats PowerShell
$variables ($env:TEMP,$server, etc.) silently. Two options: (1) single-quote the-Commandarg so Bash passes$literally to PowerShell; (2) write a.ps1file with a heredoc (single-quoted delimiter preserves$) and use-File. On macOS via Parallels, theprlctl->cmd.exe->powershell.exechain adds extra escaping layers;.ps1files are more reliable for complex scripts but inline-Commandwith single quotes works for short commands. - Always use `-ExecutionPolicy Bypass` when running PowerShell commands or scripts. Windows blocks unsigned scripts by default.
- Script file location -- persistent scripts should go in the agent harness's scripts directory for the project (
.claude/scripts/,.github/scripts/,.cursor/scripts/,.gemini/scripts/, etc. depending on the harness). Ephemeral or throwaway scripts should go in a projecttmp/directory (which should be.gitignored). Do not write scripts to./root or/tmp/. - Do not modify model metadata without explicit user direction
- Always call
$model.SaveChanges()to persist modifications; without it, changes are discarded - For macOS users running PBI Desktop in Parallels, see parallels-macos.md
- Validation hooks are active for this plugin; they validate DAX references, enforce measure metadata, check referential integrity, and report compatibility level upgrade opportunities. Toggle checks in
hooks/config.yaml.
1. Prerequisites
| Requirement | Description |
|---|---|
| Power BI Desktop | Open with a model loaded (.pbix or .pbip) |
| PowerShell | Available on the machine running PBI Desktop |
| NuGet CLI | For package installation (winget install Microsoft.NuGet) |
| TOM NuGet Package | Microsoft.AnalysisServices.retail.amd64 -- model metadata |
| ADOMD.NET Package | Microsoft.AnalysisServices.AdomdClient.retail.amd64 -- DAX queries |
Install both packages only if not already present:
$pkgDir = "$env:TEMP\tom_nuget"
if (-not (Test-Path "$pkgDir\Microsoft.AnalysisServices.retail.amd64")) {
nuget install Microsoft.AnalysisServices.retail.amd64 -OutputDirectory $pkgDir -ExcludeVersion
}
if (-not (Test-Path "$pkgDir\Microsoft.AnalysisServices.AdomdClient.retail.amd64")) {
nuget install Microsoft.AnalysisServices.AdomdClient.retail.amd64 -OutputDirectory $pkgDir -ExcludeVersion
}Packages install DLLs under lib\net45\. Load with Add-Type -Path.
If a TOM operation fails with a compatibility level error or missing type, the.retail.amd64package may be too old. A newer package (Microsoft.AnalysisServices, .NET 8+) ships with more recent TOM features. See daxlib.md for details on package differences.
2. Quickstart
Find the port, load TOM, connect, enumerate -- in one script:
# Find ports (deduped; netstat lists IPv4 and IPv6 entries per port)
$pids = (Get-Process msmdsrv -ErrorAction SilentlyContinue).Id
$ports = netstat -ano | Select-String "LISTENING" |
Where-Object { $pids -contains ($_ -split "\s+")[-1] } |
ForEach-Object { ($_ -split "\s+")[2] -replace ".*:" } |
Select-Object -Unique
# Load TOM
$basePath = "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.retail.amd64\lib\net45"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Core.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.dll"
# Connect to the first port that hosts a model; skip thin-report engines (0 databases)
$server = New-Object Microsoft.AnalysisServices.Tabular.Server
foreach ($p in $ports) {
$server.Connect("Data Source=localhost:$p")
if ($server.Databases.Count -eq 0) {
Write-Output "localhost:$p hosts no model (thin report); trying next port"
$server.Disconnect()
continue
}
break
}
$model = $server.Databases[0].Model
# Enumerate
foreach ($table in $model.Tables) {
Write-Output "TABLE: [$($table.Name)] ($($table.Columns.Count) cols, $($table.Measures.Count) measures)"
}
Write-Output "Relationships: $($model.Relationships.Count)"
$server.Disconnect()Port discovery methods:
| Method | Install Type | Command |
|---|---|---|
| Port file | Non-Store PBI Desktop | Get-Content "$env:LOCALAPPDATA\Microsoft\Power BI Desktop\AnalysisServicesWorkspaces\*\Data\msmdsrv.port.txt" |
| Port file | Store PBI Desktop | Get-Content "$env:LOCALAPPDATA\Packages\Microsoft.MicrosoftPowerBIDesktop_*\LocalState\AnalysisServicesWorkspaces\*\Data\msmdsrv.port.txt" |
| netstat | Any | `netstat -ano \ |
2a. Correlating Ports to Reports (Multiple Instances)
A port alone does not identify the report it serves; correlate before connecting to avoid modifying the wrong model. With the pbir CLI and Desktop's "external tool access" preview feature enabled, pbir desktop list shows each Desktop PID with the exact file it has open. Map ports to those PIDs through the process tree (each msmdsrv.exe is a child of its PBIDesktop.exe):
$conns = Get-NetTCPConnection -State Listen
foreach ($proc in Get-Process msmdsrv -ErrorAction SilentlyContinue) {
$port = ($conns | Where-Object OwningProcess -eq $proc.Id | Select-Object -First 1).LocalPort
$parent = (Get-WmiObject Win32_Process -Filter "ProcessId=$($proc.Id)").ParentProcessId
Write-Output "port $port -> msmdsrv $($proc.Id) -> PBIDesktop $parent"
}An engine reporting Databases: 0 is a thin report's workspace; no local model exists. Query the remote model instead (pbir model -q routes there automatically).
3. Loading TOM, Connecting, and Saving Changes
Load Assemblies
$basePath = "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.retail.amd64\lib\net45"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Core.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.dll"
Add-Type -Path "$basePath\Microsoft.AnalysisServices.Tabular.Json.dll"Connect
$server = New-Object Microsoft.AnalysisServices.Tabular.Server
$server.Connect("Data Source=localhost:<PORT>")
# PBI Desktop always has exactly one database
$db = $server.Databases[0]
$model = $db.ModelSave Changes
Only save after all changes are made. After modifications, persist with:
$model.SaveChanges()Changes appear immediately in PBI Desktop. The user cannot undo with Ctrl+Z in Power BI, which is a disadvantage of this approach.
Disconnect
IMPORTANT: Remember to disconnect after modifications are done. NEVER remain connected, which can lead to orphaned processes.
$server.Disconnect()Connection Properties
Write-Output "Server: $($server.Name)"
Write-Output "Version: $($server.Version)"
Write-Output "Database: $($db.Name)"
Write-Output "Compatibility: $($db.CompatibilityLevel)"4. Refreshing the Model
Trigger a data refresh via TMSL (Tabular Model Scripting Language) or TOM's RequestRefresh API. This re-executes Power Query/M expressions and reloads data into the VertiPaq engine.
# Full refresh of a single table via TMSL
$dbName = $server.Databases[0].Name
$tmsl = '{ "refresh": { "type": "full", "objects": [{ "database": "' + $dbName + '", "table": "Sales" }] } }'
$server.Execute($tmsl)
# Or via TOM RequestRefresh API
$model.Tables["Sales"].RequestRefresh([Microsoft.AnalysisServices.Tabular.RefreshType]::Full)
$model.SaveChanges()| Refresh Type | Behaviour |
|---|---|
full | Drop data, re-query source, recalculate DAX |
calculate | Recalculate DAX only (no source query) |
automatic | Engine decides per-partition what's needed |
dataOnly | Re-query source but skip DAX recalculation |
For detailed examples and all refresh methods, see refresh-model.md.
5. Querying with DAX
Load ADOMD.NET
Add-Type -Path "$env:TEMP\tom_nuget\Microsoft.AnalysisServices.AdomdClient.retail.amd64\lib\net45\Microsoft.AnalysisServices.AdomdClient.dll"Open a Connection
$conn = New-Object Microsoft.AnalysisServices.AdomdClient.AdomdConnection
$conn.ConnectionString = "Data Source=localhost:<PORT>"
$conn.Open()Execute a Query
All queries should preferably use SUMMARIZECOLUMNS. Check dax.guide online for information about DAX functions, if necessary.
Important: ADOMD.NET returns fully-qualified column names without quotes around the table name (e.g., Brands[Brand Class] not Brand Class; measure projections come back as [@Alias]). Do not access columns by short name ($reader["Brand Class"]) -- it fails silently and returns blank. Use $reader.GetName($i) to discover column names, then access by index:
$cmd = $conn.CreateCommand()
$cmd.CommandText = "EVALUATE SUMMARIZECOLUMNS('Table'[Column], ""@MeasureName"", [Measure])"
$reader = $cmd.ExecuteReader()
# Always iterate by index and use GetName() to map columns
while ($reader.Read()) {
for ($i = 0; $i -lt $reader.FieldCount; $i++) {
Write-Output "$($reader.GetName($i)): $($reader.GetValue($i))"
}
Write-Output "---"
}
$reader.Close()DAX Rules
- Always fully qualify column references with single-quoted table names:
'Sales'[Amount], not[Amount]. This applies everywhere -- measures, calculated columns, queries. Unqualified columns cause ambiguity errors. - Table names are always single-quoted in DAX:
'Sales'[Amount],'D&D 5E Monsters'[CR]. Even simple names likeSalesshould be quoted as'Sales'for consistency. - Measure references are the only exception -- they are always unqualified:
[Total Revenue] - String literals in DAX use double quotes, escaped as
""inside PowerShell here-strings
Query Patterns
# Full table scan
$cmd.CommandText = "EVALUATE 'Sales'"
# Filtered with CALCULATETABLE
$cmd.CommandText = "EVALUATE CALCULATETABLE('Sales', 'Sales'[Region] = ""West"")"
# Aggregation
$cmd.CommandText = "EVALUATE SUMMARIZECOLUMNS('Date'[Year], ""@Total"", SUM('Sales'[Amount]))"
# Scalar via ROW
$cmd.CommandText = "EVALUATE ROW(""Result"", COUNTROWS('Sales'))"
# DMV queries (model metadata via SQL-like syntax)
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_TABLES"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_MEASURES"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_COLUMNS"
$cmd.CommandText = "SELECT * FROM `$SYSTEM.TMSCHEMA_RELATIONSHIPS"Close Connection
$conn.Close()6. Modifying a Semantic Model
All modifications require a TOM connection (section 3). Call $model.SaveChanges() after each batch of changes.
A. CRUD by Object Type
For full CRUD examples of every object type, see tom-object-types.md.
Common object types and their TOM collections (not exhaustive -- see Microsoft TOM API docs for the full namespace):
| Object | Collection | Create | Read | Update | Delete |
|---|---|---|---|---|---|
| Table | $model.Tables | New-Object ...Table | $model.Tables["Name"] | Set properties | .Remove($obj) |
| Column | $table.Columns | New-Object ...DataColumn | $table.Columns["Name"] | Set properties | .Remove($obj) |
| Measure | $table.Measures | New-Object ...Measure | $table.Measures["Name"] | Set properties | .Remove($obj) |
| Calculated Column | $table.Columns | New-Object ...CalculatedColumn | Filter by type | Set .Expression | .Remove($obj) |
| Calculated Table | $model.Tables | Table + calculated partition | Check partition type | Set partition expr | .Remove($obj) |
| Relationship | $model.Relationships | New-Object ...SingleColumnRelationship | Index or filter | Set properties | .Remove($obj) |
| Hierarchy | $table.Hierarchies | New-Object ...Hierarchy | $table.Hierarchies["Name"] | Modify levels | .Remove($obj) |
| Role | $model.Roles | New-Object ...ModelRole | $model.Roles["Name"] | Set permissions | .Remove($obj) |
| Perspective | $model.Perspectives | New-Object ...Perspective | $model.Perspectives["Name"] | Toggle membership | .Remove($obj) |
| Culture | $model.Cultures | New-Object ...Culture | $model.Cultures["en-US"] | Set translations | .Remove($obj) |
| Partition | $table.Partitions | New-Object ...Partition | $table.Partitions["Name"] | Set source/expression | .Remove($obj) |
| Annotation | Any object | $obj.Annotations.Add(...) | $obj.Annotations["Key"] | Set .Value | .Remove($obj) |
| Expression | $model.Expressions | New-Object ...NamedExpression | $model.Expressions["Name"] | Set .Expression | .Remove($obj) |
| Data Source | $model.DataSources | New-Object ...StructuredDataSource | $model.DataSources["Name"] | Set connection | .Remove($obj) |
| Calculation Group | $model.Tables | Table with CalculationGroup | Filter by type | Add/remove items | .Remove($obj) |
Quick examples (inline):
# Add a measure
$m = New-Object Microsoft.AnalysisServices.Tabular.Measure
$m.Name = "Total Revenue"
$m.Expression = "SUM(Sales[Amount])"
$m.FormatString = "`$#,0"
$m.Description = "Sum of all sales amounts"
$model.Tables["Sales"].Measures.Add($m)
# Add a relationship
$rel = New-Object Microsoft.AnalysisServices.Tabular.SingleColumnRelationship
$rel.Name = "Sales_to_Date"
$rel.FromColumn = $model.Tables["Sales"].Columns["DateKey"]
$rel.ToColumn = $model.Tables["Date"].Columns["DateKey"]
$rel.FromCardinality = [Microsoft.AnalysisServices.Tabular.RelationshipEndCardinality]::Many
$rel.ToCardinality = [Microsoft.AnalysisServices.Tabular.RelationshipEndCardinality]::One
$model.Relationships.Add($rel)
# Rename a column
$model.Tables["Sales"].Columns["amt"].Name = "Amount"
# Hide a table
$model.Tables["Bridge"].IsHidden = $true
# Delete a measure
$m = $model.Tables["Sales"].Measures["Old Measure"]
$model.Tables["Sales"].Measures.Remove($m)
# Add a role with RLS
$role = New-Object Microsoft.AnalysisServices.Tabular.ModelRole
$role.Name = "Region Filter"
$role.ModelPermission = [Microsoft.AnalysisServices.Tabular.ModelPermission]::Read
$model.Roles.Add($role)
$tp = New-Object Microsoft.AnalysisServices.Tabular.TablePermission
$tp.Table = $model.Tables["Sales"]
$tp.FilterExpression = "[Region] = USERNAME()"
$role.TablePermissions.Add($tp)
$model.SaveChanges()B. Discovering Object Types, Properties, and Setting Values
For complete TOM object type tables, PowerShell reflection patterns for discovering properties and enum values, and reading/setting property examples, see `references/tom-object-types.md`.
7. Validating DAX Expressions
Before saving measure/column expressions, validate them by test-executing against the live model. This catches syntax errors, missing column references, and circular dependencies without persisting bad metadata.
# Validate a DAX expression before adding it as a measure
$testExpr = "SUM('Sales'[Amount]) / COUNTROWS('Sales')"
$cmd = $conn.CreateCommand()
$cmd.CommandText = "EVALUATE ROW(`"@Test`", $testExpr)"
try {
$reader = $cmd.ExecuteReader()
$reader.Close()
Write-Output "VALID"
} catch {
Write-Output "INVALID: $($_.Exception.Message)"
}For calculated table expressions, wrap in COUNTROWS:
$tableExpr = "CALENDAR(DATE(2020,1,1), DATE(2030,12,31))"
$cmd.CommandText = "EVALUATE ROW(`"@Count`", COUNTROWS($tableExpr))"For filter expressions (RLS), test with CALCULATETABLE:
$filterExpr = "'Sales'[Region] = `"West`""
$cmd.CommandText = "EVALUATE CALCULATETABLE(ROW(`"@OK`", 1), $filterExpr)"8. Transactions and Rollback
SaveChanges() applies all pending modifications in a single implicit transaction. If any object fails validation, the entire batch rolls back automatically.
For multi-step workflows where inspection or rollback is needed before committing:
try {
# Make changes (not yet persisted)
$model.Tables["Sales"].Measures["Revenue"].Name = "Total Revenue"
$model.Tables["Sales"].Measures["Cost"].Name = "Total Cost"
# Inspect before committing (changes are local to this connection)
foreach ($m in $model.Tables["Sales"].Measures) {
Write-Output " [$($m.Name)]"
}
# Commit all changes atomically
$model.SaveChanges()
Write-Output "Committed"
} catch {
# Discard all uncommitted changes
$model.UndoLocalChanges()
Write-Output "Rolled back: $($_.Exception.Message)"
}UndoLocalChanges() discards all modifications made since the last SaveChanges(). This is the rollback mechanism for PBI Desktop; there is no explicit begin/commit transaction API on the local Analysis Services instance.
9. Model Validation
Validate Before Saving
The TOM API does not expose a public Validate() method. Validation happens implicitly during SaveChanges() (which rolls back the entire batch on failure). For pre-save validation, inspect objects manually:
# Check measures have valid expressions (non-empty)
foreach ($m in ($model.Tables | ForEach-Object { $_.Measures }) ) {
if ([string]::IsNullOrWhiteSpace($m.Expression)) {
Write-Output "WARNING: Measure [$($m.Name)] in [$($m.Table.Name)] has no expression"
}
}
# Check relationships reference valid columns
foreach ($rel in $model.Relationships) {
$sr = [Microsoft.AnalysisServices.Tabular.SingleColumnRelationship]$rel
if ($sr.FromColumn -eq $null -or $sr.ToColumn -eq $null) {
Write-Output "WARNING: Relationship [$($sr.Name)] has null column references"
}
}
# Check for duplicate measure names across tables
$names = @{}
foreach ($m in ($model.Tables | ForEach-Object { $_.Measures })) {
if ($names.ContainsKey($m.Name)) {
Write-Output "WARNING: Duplicate measure name [$($m.Name)] in [$($m.Table.Name)] and [$($names[$m.Name])]"
}
$names[$m.Name] = $m.Table.Name
}10. Finding the File Path and Editing Metadata Files
Find the Open File Path
TOM does not expose the .pbix/.pbip file path directly.
Primary method — Desktop bridge: pbir desktop list reports the exact file each running instance has open (requires the pbir CLI and the "external tool access" preview feature; see Section 2a). Use the methods below only when that is unavailable.
Fallback — FileHistory in User.zip (works for Store and non-Store):
# Read the most recently opened file from PBI Desktop's settings
$userZip = "$env:USERPROFILE\Microsoft\Power BI Desktop Store App\User.zip"
# For non-Store installs: "$env:LOCALAPPDATA\Microsoft\Power BI Desktop\User.zip"
Add-Type -Assembly System.IO.Compression.FileSystem
$z = [System.IO.Compression.ZipFile]::OpenRead($userZip)
$entry = $z.Entries | Where-Object { $_.Name -eq 'Settings.xml' }
$reader = New-Object System.IO.StreamReader($entry.Open())
$content = $reader.ReadToEnd()
$reader.Close()
$z.Dispose()
# Extract FileHistory entries (ordered by lastAccessedDate, most recent first)
$history = ($content -split '(?=<Entry)' | Where-Object { $_ -match 'FileHistory' })[0]
$json = [regex]::Match($history, 'Value="s\[(.*?)\]"').Groups[1].Value -replace '"', '"'
$files = $json | ConvertFrom-Json
$files | Select-Object filePath, lastAccessedDate | Format-Table -AutoSizeThe first entry is the most recently opened file. Files on the Mac (via Parallels) appear as \\Mac\Home\... paths.
Limitation: This is an imperfect method — it reads recent file history, not the currently open file. If multiple PBI Desktop instances are open, or the most recently accessed file in history isn't the one currently open, the result may be wrong. Confirm with the user if there is any ambiguity.
Fallback — window title (non-Store PBI Desktop only):
Get-Process PBIDesktop -ErrorAction SilentlyContinue | Select-Object Id, MainWindowTitleNote: Store PBI Desktop (from Microsoft Store / WindowsApps) does not expose the file path in the window title — use the User.zip method above instead.
Fallback — msmdsrv command line (gives workspace path, not file path):
# Useful for finding the port; does NOT reveal the source file path
(Get-WmiObject Win32_Process -Filter "Name='msmdsrv.exe'").CommandLineEditing PBIP Metadata Files (Connection, Report, Model)
For .pbip projects, metadata files are human-readable JSON/TMDL on disk and can be read and modified directly.
Common targets:
| File | Purpose | Skill |
|---|---|---|
<Name>.Report/definition.pbir | Report-to-model connection (byPath or byConnection) | pbip |
<Name>.Report/definition/report.json | Report-level settings, theme, filters | pbir-format |
<Name>.SemanticModel/definition/*.tmdl | Model metadata (tables, measures, relationships) | tmdl |
<Name>.SemanticModel/definition/expressions.tmdl | M/Power Query shared expressions and parameters | tmdl |
For syntax, structure, and editing patterns for these files, load the relevant skill from the pbip plugin:
- `pbip` -- project structure, file types,
.pbirconnection, forking - `pbir-format` --
report.json,visual.json, themes, filters, PBIR JSON schemas - `tmdl` -- TMDL syntax, measures, columns, roles, relationships
Reloading External File Edits
Power BI Desktop does not watch for external file changes; edits made on disk while a report is open are silently ignored or overwritten on the next Desktop save. To apply changes, in order of preference:
1. TOM modifications ($model.SaveChanges()) apply to the running instance immediately. Prefer this for model metadata. 2. PBIR report-definition edits (pages, visuals) hot-reload into the open canvas with pbir desktop refresh "Report.Report" (PBIP/PBIR only, not .pbix; requires the preview feature). Theme JSON edits under StaticResources do NOT hot-reload; close and reopen instead. If the instance has unsaved changes, Desktop saves first and may overwrite the on-disk edit. 3. Everything else (TMDL edits on disk, theme files, .pbix): close Power BI Desktop, edit, reopen.
For report (PBIR) files specifically, the Desktop Bridge reloads on-disk edits into the open canvas without reopening (the file.reload/v1 pipe method, with the powerbi-desktop npm CLI as a fallback); see section 13. Model (TMDL) on-disk edits still require close-and-reopen, or use live TOM SaveChanges() as above.
Microsoft Documentation
| Topic | URL |
|---|---|
| TOM API Reference | learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.tabular |
| TOM Overview | learn.microsoft.com/en-us/analysis-services/tom/introduction-to-the-tabular-object-model-tom-in-analysis-services-amo |
| ADOMD.NET Reference | learn.microsoft.com/en-us/dotnet/api/microsoft.analysisservices.adomdclient |
| Client Libraries | learn.microsoft.com/en-us/analysis-services/client-libraries |
| DMV Reference | learn.microsoft.com/en-us/analysis-services/instances/use-dynamic-management-views-dmvs-to-monitor-analysis-services |
| DAX Reference | dax.guide |
| Compatibility Levels | learn.microsoft.com/en-us/analysis-services/tabular-models/compatibility-level-for-tabular-models-in-analysis-services |
To retrieve current TOM/ADOMD.NET reference docs, use microsoft_docs_search + microsoft_docs_fetch (MCP) if available, otherwise mslearn search + mslearn fetch (CLI). Search based on the user's request and run multiple searches as needed to ensure sufficient context before proceeding.
11. Debugging DAX with EVALUATEANDLOG
EVALUATEANDLOG(<Value>, [Label], [MaxRows]) wraps any DAX expression, returns it unchanged, and emits intermediate results as JSON via a trace event. Works in PBI Desktop only.
Programmatic capture via the TOM Trace API eliminates the need for external tools (DAX Debug Output, SQL Server Profiler, DAX Studio). Subscribe to the DAXEvaluationLog trace event (enum ID 135), capture events with a synchronized ArrayList via Register-ObjectEvent, and parse the JSON from $Event.SourceEventArgs.TextData.
Critical implementation detail: Register-ObjectEvent -Action runs in a separate PowerShell runspace. $global: variables inside the action block do not share scope. Pass a synchronized collection via -MessageData:
$evalEvents = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList))
$job = Register-ObjectEvent -InputObject $trace -EventName "OnEvent" -MessageData $evalEvents -Action {
$Event.MessageData.Add($Event.SourceEventArgs) | Out-Null
}Trace delivery is asynchronous: DAXEvaluationLog events typically arrive 2-3.5 seconds after the query returns, so a short fixed sleep misses them. Poll the captured-event count (up to ~10s in 500ms steps) before reading results. Warm-cache runs still emit the event; do not rely on cache clearing to make it fire. Clear the VertiPaq cache only when cold-cache timings are needed:
$server.Execute('{ "clearCache": { "object": { "database": "' + $db.Name + '" } } }') | Out-NullCommon debugging patterns:
| Pattern | Approach |
|---|---|
| Measure chain decomposition | Wrap each intermediate step: EVALUATEANDLOG([Step1], "Label1") |
| Filter context inspection | Trace CALCULATE with vs without ALL/REMOVEFILTERS |
| BLANK vs zero detection | Trace the value before a comparison; BLANK = 0 is TRUE in DAX |
| Variable context trap | Trace VAR value alongside CALCULATE result; proves VAR is not re-evaluated |
| Grand total diagnosis | Trace numerator + denominator at row vs total grain |
| Table expression inspection | Wrap CALCULATETABLE result; trace shows actual rows feeding an aggregate |
For full setup, JSON payload structure, event batching behavior, and all debugging patterns, see evaluateandlog-debugging.md.
12. Performance Profiling
Programmatic equivalent of DAX Studio's Server Timings. Subscribe to QueryEnd, VertiPaqSEQueryEnd, and VertiPaqSEQueryCacheMatch trace events to measure Formula Engine (FE) vs Storage Engine (SE) time per query.
Key formula: FE time = Total duration - sum(SE scan durations)
Important: VertiPaqSEQueryCacheMatch does NOT support Duration or CpuTime columns; adding them causes $trace.Update() to throw. Only add TextData + EventClass for cache match events.
Workflow: 1. Create trace with performance events (see reference for column compatibility) 2. Clear cache (TMSL clearCache) for cold timings 3. Execute DAX via ADOMD.NET 4. Parse trace events: QueryEnd for total, VertiPaqSEQueryEnd for per-scan SE durations 5. Compare cold vs warm cache to measure cache benefit
Statistical sampling: Single measurements are noisy. Always take 6-12 samples and compare medians (not means) before and after a change. If ranges overlap significantly, the difference is likely noise. Discard the first cold-cache run as warm-up. See the reference for a Measure-QueryMedian helper.
Visual query profiling: Construct SUMMARIZECOLUMNS queries from PBIR visual.json definitions. Column projections become group-by columns; measure projections become measure references; Aggregation.Function maps to SUM (0), MIN (1), MAX (2), COUNT (3), AVERAGE (4).
For full setup, timing interpretation, sampling patterns, and PBIR-to-DAX translation, see performance-profiling.md.
13. Working with the Report Canvas (Desktop Bridge)
The TOM connection above drives the model: tables, measures, relationships, roles, refresh. It cannot touch the report canvas (pages and visuals). Power BI Desktop exposes a second, separate local API for that: the Desktop Bridge, a per-process JSON-RPC server on the Windows named pipe \\.\pipe\pbi-desktop-bridge-<PID>. Pair the two to change the model and immediately confirm the report re-renders.
When the pbir CLI is installed, it wraps this same pipe; prefer it over driving the pipe raw:
pbir desktop list # PID + open file per instance
pbir model --% "Report.Report" -q "EVALUATE ROW(""Check"", [New Measure])" # engine-level check
pbir desktop refresh "Report.Report" # reload on-disk PBIR into the canvas
pbir desktop screenshot "Report.Report/Page Name.Page" -o verify.png # inspect renderingThe --% stop-parsing token prevents Windows PowerShell 5.1 from stripping the embedded quotes; omit it in bash or PowerShell 7+.
Without pbir, drive the pipe raw from PowerShell, the same way this skill drives TOM/ADOMD. It requires the Desktop bridge preview setting enabled (File > Options and settings > Options > Preview features, then restart). Auto-discover the PID by enumerating the pipe directory; then over JSON-RPC: application.state.get/v1 returns the open file path (currentFilePath, so the bridge can locate the PBIP on disk), file.reload/v1 reloads the on-disk PBIR into the canvas, and report.snapshot.capture/v1 returns a page PNG.
Model-plus-report loop: edit the model with TOM and $model.SaveChanges() (applies live), then reload and screenshot the report to confirm visuals reflect the change (a renamed measure, a new format string, a repaired relationship). On-disk report (PBIR) edits are picked up by reload; on-disk model (TMDL) edits and theme files under StaticResources still need a reopen, so prefer live TOM for model changes. The bridge drives the Windows app, so on macOS run it inside the Parallels VM (see parallels-macos.md).
For the full command set, PID selection, the JSON-RPC method surface (bridge.manifest, application.state.get/v1, file.reload/v1, report.snapshot.capture/v1), and how it complements the Analysis Services local API, see desktop-bridge.md. To CHANGE visuals, pages, formatting, filters, or bookmarks, route to the pbir-cli skill (reports plugin); the Desktop Bridge here only reloads and screenshots, it never edits the report.
Alternative path (only if driving the raw pipe runs into trouble, framing, encoding, or a build that changed a param shape): use the pbir desktop commands (reports plugin pbir-cli skill), which wrap these same methods. See desktop-bridge.md.
References
Skill references:
- TOM Object Types CRUD - Full CRUD examples for every object type including UDFs, Direct Lake, KPI note
- Annotations and Extended Properties - Standard PBI annotations, Tabular Editor table groups, auto date/time, field parameters, query groups, custom annotations
- Calendar Column Groups - Gregorian, fiscal, and ISO week-based calendar definitions via TOM; time units, primary/associated columns
- DAX Expression Locations - Where DAX appears in a model: measures, calculated columns/tables, calc items, format strings, detail rows, RLS, UDFs
- DAX Pitfalls - Deprecated/not-recommended functions, non-existent functions agents hallucinate from SQL/Python/M, common syntax mistakes, BLANK vs NULL
- EVALUATEANDLOG Debugging - Programmatic DAX debugging via TOM Trace API; capture intermediate results, cache clearing, six debugging patterns for common DAX issues
- Performance Profiling - DAX Server Timings via Trace API; FE/SE time split, cold/warm cache comparison, PBIR visual-to-DAX translation, trace event column compatibility
- Query Listener - Capture live visual DAX queries via DMV polling; interpret query structure, timings, filter patterns
- Export Model - Export to BIM/TMDL via Tabular Editor CLI, fab CLI, or TOM serializer
- Loading TMDL/BIM Files - Load local TMDL folders or BIM files into TOM offline; inspect, modify, serialize back, deploy via fab CLI
- VertiPaq Statistics - Column cardinality, dictionary/data size, memory by table, server timings via DMVs
- Refresh Model - All refresh methods (TMSL, TOM RequestRefresh, ADOMD.NET)
- macOS + Parallels Guide - Connecting from macOS when PBI Desktop runs in a Parallels VM
- DAX Library Packages - Installing reusable DAX UDF packages from daxlib.org; DaxLib.SVG, PowerofBI.IBCS, package structure, annotations
- Desktop Bridge (report canvas) - Reload + screenshot the open report canvas over the raw named-pipe JSON-RPC API (PowerShell; or the
pbir desktopcommands); pairing model (TOM) edits with report verification
CLI tools at the skill root:
- `daxlib` -- CLI for browsing, downloading, and installing DAX library packages from daxlib.org. Script at
daxlib.sh(requires bash + jq). Model operations (add/update/remove) callscripts/daxlib-tom/viadotnet run(requires .NET 8 SDK). On macOS, model operations route through Parallels automatically. See daxlib.md for full command reference.
Agents:
- `query-listener` -- Dispatch to capture live visual DAX queries in real time; polls
DISCOVER_SESSIONSand reports query text + timings
Example scripts in `scripts/`:
connect-and-enumerate.ps1- Connect to PBI Desktop and list all tables, columns, measures, relationshipsexplore-model.ps1- Hierarchical metadata enumeration (tables, columns, measures, hierarchies, partitions, relationships, roles, perspectives, cultures, expressions, data sources)query-dax.ps1- Execute DAX queries via ADOMD.NET with formatted outputrefresh-table.ps1- Refresh a table or entire model via TMSL with configurable refresh typemodify-tom-objects.ps1- Create table, rename measures, set folders/formats, hide columns, create relationship (with undo)create-field-parameter.ps1- Create a field parameter table from a list of measures with all required metadatadebug-dax.ps1- Debug DAX with EVALUATEANDLOG trace capture and performance timings; auto-detects port, enumerates model measures, providesInvoke-DebugQueryhelperload-tmdl.ps1- Load a local TMDL folder or BIM file into TOM offline (no running engine), enumerate the model, optionally add a measure and save backconnect-from-mac.sh- macOS wrapper that runs PowerShell scripts in a Parallels VM viaprlctl exec
External references:
- TOM API Docs
- ADOMD.NET Docs
- Analysis Services Client Libraries
- DAX Guide - use
dax.guide/<function>/for individual function reference
#!/bin/bash
#
# daxlib.sh: CLI for browsing, downloading, and installing DAX library
# packages from daxlib.org into Power BI Desktop semantic models.
#
# Script-based replacement for the daxlib binary; requires bash 3.2+, jq,
# and either gh or curl for HTTP requests.
#
# Standalone operations (search, info, versions, functions, download)
# work without Power BI Desktop. Model operations (add, update, remove,
# installed) shell out to PowerShell for TOM/TmdlSerializer access.
set -euo pipefail
# jq is required for registry parsing; fail fast with a clear message.
# Skip the check for usage-only invocations.
if [[ $# -gt 0 && "$1" != "--help" && "$1" != "-h" ]]; then
command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but not installed (https://jqlang.github.io/jq/)" >&2; exit 1; }
fi
# #region Constants
GITHUB_RAW="https://raw.githubusercontent.com/daxlib/daxlib/main/packages"
GITHUB_API="https://api.github.com/repos/daxlib/daxlib"
# #endregion
# #region HTTP
http_get() {
# Fetches a URL via gh CLI (authenticated, 5000 req/hr).
# Falls back to curl if gh is unavailable.
local url="$1"
if command -v gh &>/dev/null; then
if [[ "$url" == *"api.github.com"* ]]; then
local path="${url#https://api.github.com}"
gh api "$path" --cache 1h 2>/dev/null && return
else
gh api "$url" --cache 1h 2>/dev/null && return
fi
fi
# Fallback to curl
if command -v curl &>/dev/null; then
curl -sSfL -H "User-Agent: daxlib-cli" "$url" 2>/dev/null && return
fi
echo "Error: neither gh nor curl available" >&2
return 1
}
# #endregion
# #region Registry
semver_sort_key() {
# Converts a semver string to a zero-padded sortable key.
local v="${1%%-*}"
local major minor patch
IFS='.' read -r major minor patch <<< "$v"
printf '%06d%06d%06d' "${major:-0}" "${minor:-0}" "${patch:-0}"
}
package_letter() {
# Returns the first character of a package ID, lowercased.
echo "${1:0:1}" | tr '[:upper:]' '[:lower:]'
}
resolve_latest_stable() {
# Returns the highest non-prerelease version for a package.
local id="$1"
local versions
versions="$(list_versions "$id")" || return 1
# Filter stable (no hyphen)
local stable
stable=$(echo "$versions" | grep -v '-' || true)
local candidates="${stable:-$versions}"
if [[ -z "$candidates" ]]; then
echo "No versions found for '${id}'" >&2
return 1
fi
echo "$candidates" | head -1
}
list_versions() {
# Lists all published versions for a package, newest first.
local id="$1"
local letter
letter="$(package_letter "$id")"
local url="${GITHUB_API}/contents/packages/${letter}/$(echo "$id" | tr '[:upper:]' '[:lower:]')"
local body
body="$(http_get "$url")" || { echo "Package '${id}' not found in daxlib registry" >&2; return 1; }
echo "$body" | jq -r '.[] | select(.type == "dir") | .name' 2>/dev/null | while IFS= read -r v; do
printf '%s %s\n' "$(semver_sort_key "$v")" "$v"
done | sort -rn | awk '{print $2}'
}
fetch_manifest() {
# Downloads and parses the manifest.daxlib JSON for a specific version.
local id="$1" version="$2"
local letter
letter="$(package_letter "$id")"
local url="${GITHUB_RAW}/${letter}/$(echo "$id" | tr '[:upper:]' '[:lower:]')/${version}/manifest.daxlib"
http_get "$url"
}
fetch_functions_tmdl() {
# Downloads the functions.tmdl file for a specific package version.
local id="$1" version="$2"
local letter
letter="$(package_letter "$id")"
local url="${GITHUB_RAW}/${letter}/$(echo "$id" | tr '[:upper:]' '[:lower:]')/${version}/lib/functions.tmdl"
http_get "$url"
}
search_packages() {
# Searches for packages matching a query string by fetching the repo tree.
local query="$1"
local query_lower
query_lower="$(echo "$query" | tr '[:upper:]' '[:lower:]')"
local url="${GITHUB_API}/git/trees/main?recursive=1"
local body
body="$(http_get "$url")" || { echo "Failed to fetch repository tree" >&2; return 1; }
echo "$body" | jq -r '.tree[].path' 2>/dev/null | \
grep '/manifest\.daxlib$' | \
while IFS= read -r path; do
# packages/{letter}/{id}/{ver}/manifest.daxlib
local parts
IFS='/' read -ra parts <<< "$path"
[[ ${#parts[@]} -eq 5 && "${parts[0]}" == "packages" ]] || continue
local pkg_id="${parts[2]}"
local pkg_lower
pkg_lower="$(echo "$pkg_id" | tr '[:upper:]' '[:lower:]')"
if [[ "$pkg_lower" == *"$query_lower"* ]]; then
echo "$pkg_id"
fi
done | sort -u
}
# #endregion
# #region TMDL Parser
parse_function_names() {
# Extracts function names from a functions.tmdl file.
# Handles both quoted ('Package.Name') and unquoted (Name) formats.
local tmdl="$1"
echo "$tmdl" | grep -E "^function " | while IFS= read -r line; do
if [[ "$line" == *"'"* ]]; then
echo "$line" | sed "s/^function '\\([^']*\\)'.*/\\1/"
else
echo "$line" | sed 's/^function \([^ =]*\).*/\1/'
fi
done
}
extract_function_block() {
# Extracts a complete function block (with doc comments) from TMDL.
# Outputs from the first preceding /// line through to the next function or EOF.
local tmdl="$1" name="$2"
local name_lower
name_lower="$(echo "$name" | tr '[:upper:]' '[:lower:]')"
local in_doc=false in_func=false matched=false
local doc_buffer=""
echo "$tmdl" | while IFS= read -r line; do
local trimmed
trimmed="$(echo "$line" | sed 's/^[[:space:]]*//')"
# Doc comment line
if [[ "$trimmed" == "///"* ]] && ! $in_func; then
if ! $in_doc; then
# Start new doc buffer; flush any pending output
doc_buffer=""
in_doc=true
fi
doc_buffer="${doc_buffer}${line}
"
continue
fi
# Function declaration at root level
if [[ "$trimmed" == "function "* ]] && [[ "$line" != $'\t'* ]]; then
# End previous function if we were in one
if $matched; then
# We've hit the next function; stop
break
fi
in_doc=false
in_func=true
# Check if this is the target function
local func_name
if [[ "$trimmed" == *"'"* ]]; then
func_name=$(echo "$trimmed" | sed "s/^function '\\([^']*\\)'.*/\\1/")
else
func_name=$(echo "$trimmed" | sed 's/^function \([^ =]*\).*/\1/')
fi
local func_lower
func_lower="$(echo "$func_name" | tr '[:upper:]' '[:lower:]')"
if [[ "$func_lower" == "$name_lower" ]] || [[ "$func_lower" == *".${name_lower}" ]]; then
matched=true
# Output doc buffer and this line
if [[ -n "$doc_buffer" ]]; then
printf '%s' "$doc_buffer"
fi
echo "$line"
fi
doc_buffer=""
continue
fi
if $matched && $in_func; then
echo "$line"
fi
if ! $in_func; then
doc_buffer=""
in_doc=false
fi
done
}
extract_params_signature() {
# Extracts a compact parameter signature from a function block.
local block="$1"
local in_params=false
local params=""
while IFS= read -r line; do
local trimmed
trimmed="$(echo "$line" | sed 's/^[[:space:]]*//')"
if [[ "$trimmed" == "(" || "$trimmed" == *"(" ]]; then
in_params=true
continue
fi
if $in_params; then
if [[ "$trimmed" == ")"* ]]; then
break
fi
[[ "$trimmed" == "//"* ]] && continue
# Clean: remove trailing comma, inline comments
local clean
clean=$(echo "$trimmed" | sed 's|//.*||' | sed 's/,[[:space:]]*$//' | sed 's/[[:space:]]*$//')
if [[ -n "$clean" ]]; then
if [[ -n "$params" ]]; then
params="${params}, ${clean}"
else
params="$clean"
fi
fi
fi
done <<< "$block"
echo "(${params})"
}
filter_functions_tmdl() {
# Filters a functions.tmdl to include only the specified functions.
local tmdl="$1"
shift
local output=""
for name in "$@"; do
local block
block="$(extract_function_block "$tmdl" "$name")"
if [[ -n "$block" ]]; then
if [[ -n "$output" ]]; then
output="${output}
${block}"
else
output="$block"
fi
fi
done
echo "$output"
}
# #endregion
# #region daxlib-tom Helper
find_daxlib_tom() {
# Locates the daxlib-tom .csproj project directory.
# Search order: sibling scripts/daxlib-tom/, DAXLIB_TOM_DIR env var.
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
# Skill layout: connect-pbid/daxlib.sh -> connect-pbid/scripts/daxlib-tom/
local candidate="$script_dir/scripts/daxlib-tom"
if [[ -f "$candidate/daxlib-tom.csproj" ]]; then
echo "$candidate"
return
fi
# Walk up
local dir="$script_dir"
for _ in 1 2 3 4; do
dir="$(dirname "$dir")"
candidate="$dir/scripts/daxlib-tom"
if [[ -f "$candidate/daxlib-tom.csproj" ]]; then
echo "$candidate"
return
fi
candidate="$dir/daxlib-tom"
if [[ -f "$candidate/daxlib-tom.csproj" ]]; then
echo "$candidate"
return
fi
done
# Env var
if [[ -n "${DAXLIB_TOM_DIR:-}" && -f "$DAXLIB_TOM_DIR/daxlib-tom.csproj" ]]; then
echo "$DAXLIB_TOM_DIR"
return
fi
return 1
}
detect_parallels_vm() {
# Finds the first running Parallels VM name.
if [[ -n "${DAXLIB_VM:-}" ]]; then
echo "$DAXLIB_VM"
return
fi
command -v prlctl &>/dev/null || return 1
local json
json="$(prlctl list --all -j 2>/dev/null)" || return 1
echo "$json" | jq -r '.[] | select(.status == "running") | .name' 2>/dev/null | head -1
}
macos_to_unc() {
# Converts a macOS path to a Parallels shared folder UNC path.
local path="$1"
local home="${HOME:-/Users/unknown}"
if [[ "$path" == "$home"* ]]; then
local relative="${path#$home}"
echo "\\\\Mac\\Home${relative//\//\\}"
else
echo "Path '${path}' is outside \$HOME and cannot be mapped to a Parallels shared folder." >&2
echo "Move the file under your home directory or set DAXLIB_TOM_DIR." >&2
exit 1
fi
}
escape_cmd_arg() {
# Escapes cmd.exe metacharacters.
local s="$1"
s="${s//\"/\\\"}"
s="${s//&/^&}"
s="${s//|/^|}"
s="${s//</^<}"
s="${s//>/^>}"
echo "$s"
}
run_daxlib_tom() {
# Runs daxlib-tom via dotnet run. On macOS, wraps in prlctl exec.
local project_dir
project_dir="$(find_daxlib_tom)" || {
echo "Cannot find daxlib-tom project." >&2
echo "Set DAXLIB_TOM_DIR to the project directory." >&2
return 1
}
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*|Windows_NT)
dotnet run --project "$project_dir" -c Release -- "$@"
;;
*)
local vm
vm="$(detect_parallels_vm)" || {
echo "No running Parallels VM found." >&2
echo "Set DAXLIB_VM to the VM name." >&2
return 1
}
local unc_project
unc_project="$(macos_to_unc "$project_dir")"
# Convert any macOS paths in args to UNC
local converted_args=""
for arg in "$@"; do
if [[ "$arg" == /* && ( "$arg" == *.tmdl || "$arg" == */daxlib* ) ]]; then
arg="$(macos_to_unc "$arg")"
fi
converted_args="${converted_args} \"$(escape_cmd_arg "$arg")\""
done
local dotnet_cmd="dotnet run --project \"$(escape_cmd_arg "$unc_project")\" -c Release --${converted_args}"
prlctl exec "$vm" cmd.exe /c "$dotnet_cmd"
;;
esac
}
# #endregion
# #region Commands
cmd_search() {
# Searches the daxlib registry for packages matching the query.
local query="${1:-}"
if [[ -z "$query" ]]; then
echo "Usage: daxlib search <query>" >&2
exit 1
fi
echo "Searching for '${query}'..." >&2
local packages
packages="$(search_packages "$query")" || exit 1
if [[ -z "$packages" ]]; then
echo "No packages found matching '${query}'"
exit 0
fi
local count
count=$(echo "$packages" | wc -l | tr -d ' ')
echo "${count} package(s) found:"
echo
while IFS= read -r pkg; do
local versions ver manifest desc tags
versions="$(list_versions "$pkg" 2>/dev/null)" || { echo " ${pkg}"; continue; }
ver="$(echo "$versions" | head -1)"
manifest="$(fetch_manifest "$pkg" "$ver" 2>/dev/null)" || { echo " ${pkg}"; continue; }
desc=$(echo "$manifest" | jq -r '.description // ""' 2>/dev/null)
tags=$(echo "$manifest" | jq -r '.tags // ""' 2>/dev/null)
echo " ${pkg} v${ver}"
[[ -n "$desc" ]] && echo " ${desc}"
[[ -n "$tags" ]] && echo " tags: ${tags}"
echo
done <<< "$packages"
}
cmd_info() {
# Shows detailed information about a package.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib info <package-id> [--version <ver>]" >&2
exit 1
fi
local version="${OPT_VERSION:-}"
if [[ -z "$version" ]]; then
version="$(resolve_latest_stable "$id")" || exit 1
fi
local manifest
manifest="$(fetch_manifest "$id" "$version")" || exit 1
local field
field() { echo "$manifest" | jq -r ".${1} // \"-\"" 2>/dev/null; }
echo "Package: $(field id)"
echo "Version: $(field version)"
echo "Authors: $(field authors)"
echo "Description: $(field description)"
echo "Tags: $(field tags)"
local proj_url repo_url notes
proj_url=$(echo "$manifest" | jq -r '.projectUrl // empty' 2>/dev/null)
repo_url=$(echo "$manifest" | jq -r '.repositoryUrl // empty' 2>/dev/null)
notes=$(echo "$manifest" | jq -r '.releaseNotes // empty' 2>/dev/null)
[[ -n "$proj_url" ]] && echo "Project: ${proj_url}"
[[ -n "$repo_url" ]] && echo "Repository: ${repo_url}"
[[ -n "$notes" ]] && echo "Notes: ${notes}"
# Show function count
local tmdl
tmdl="$(fetch_functions_tmdl "$id" "$version" 2>/dev/null)" || return
local fn_count
fn_count=$(echo "$tmdl" | grep -cE '^function ' || true)
echo
echo "Functions: ${fn_count}"
}
cmd_versions() {
# Lists all published versions for a package.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib versions <package-id>" >&2
exit 1
fi
local versions
versions="$(list_versions "$id")" || exit 1
echo "Versions for ${id}:"
while IFS= read -r v; do
local pre=""
[[ "$v" == *-* ]] && pre=" (pre)"
echo " ${v}${pre}"
done <<< "$versions"
}
cmd_functions() {
# Lists all functions in a package with their parameter signatures.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib functions <package-id> [--version <ver>]" >&2
exit 1
fi
local version="${OPT_VERSION:-}"
if [[ -z "$version" ]]; then
version="$(resolve_latest_stable "$id")" || exit 1
fi
echo "Fetching ${id} v${version}..." >&2
local tmdl
tmdl="$(fetch_functions_tmdl "$id" "$version")" || exit 1
local names
names="$(parse_function_names "$tmdl")"
local fn_count
fn_count=$(echo "$names" | grep -c '.' || true)
echo "${id} v${version} -- ${fn_count} function(s):"
echo
while IFS= read -r name; do
[[ -n "$name" ]] || continue
local block sig doc_first
block="$(extract_function_block "$tmdl" "$name")"
sig="$(extract_params_signature "$block")"
# First line of doc comment
doc_first=$(echo "$block" | grep -m1 '///' | sed 's/^[[:space:]]*\/\/\/ *//' || true)
echo " ${name}${sig}"
[[ -n "$doc_first" ]] && echo " ${doc_first}"
echo
done <<< "$names"
}
cmd_download() {
# Downloads functions.tmdl for a package, optionally filtered.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib download <package-id> [--version <ver>] [--fn name[,name]] [--output <dir>]" >&2
exit 1
fi
local version="${OPT_VERSION:-}"
if [[ -z "$version" ]]; then
version="$(resolve_latest_stable "$id")" || exit 1
fi
echo "Downloading ${id} v${version}..." >&2
local tmdl
tmdl="$(fetch_functions_tmdl "$id" "$version")" || exit 1
local output_content="$tmdl"
if [[ ${#OPT_FUNCTIONS[@]} -gt 0 ]]; then
output_content="$(filter_functions_tmdl "$tmdl" "${OPT_FUNCTIONS[@]}")"
if [[ -z "$output_content" ]]; then
echo "No matching functions found. Available:" >&2
parse_function_names "$tmdl" | while IFS= read -r n; do
echo " $n" >&2
done
exit 1
fi
fi
local out_dir="${OPT_OUTPUT:-.}"
local filename
filename="$(echo "$id" | tr '[:upper:]' '[:lower:]').functions.tmdl"
local out_path="${out_dir}/${filename}"
echo "$output_content" > "$out_path"
local fn_count
fn_count=$(echo "$output_content" | grep -cE '^function ' || true)
echo "Wrote ${filename} (${fn_count} functions) to ${out_path}"
}
cmd_add() {
# Installs a daxlib package into a PBI Desktop model.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib add <package-id> --port <port> [--version <ver>] [--fn name[,name]]" >&2
exit 1
fi
if [[ -z "${OPT_PORT:-}" ]]; then
echo "--port is required for add" >&2
exit 1
fi
local version="${OPT_VERSION:-}"
if [[ -z "$version" ]]; then
version="$(resolve_latest_stable "$id")" || exit 1
fi
echo "Downloading ${id} v${version}..." >&2
local tmdl
tmdl="$(fetch_functions_tmdl "$id" "$version")" || exit 1
local install_tmdl="$tmdl"
if [[ ${#OPT_FUNCTIONS[@]} -gt 0 ]]; then
install_tmdl="$(filter_functions_tmdl "$tmdl" "${OPT_FUNCTIONS[@]}")"
if [[ -z "$install_tmdl" ]]; then
echo "No matching functions found." >&2
exit 1
fi
fi
local temp
temp="$(write_temp_tmdl "$install_tmdl")"
trap "rm -f '$temp'" EXIT
local tom_args=("add" "$OPT_PORT" "$temp")
if [[ ${#OPT_FUNCTIONS[@]} -gt 0 ]]; then
local fn_arg
fn_arg="$(IFS=,; echo "${OPT_FUNCTIONS[*]}")"
tom_args+=("--fn" "$fn_arg")
fi
run_daxlib_tom "${tom_args[@]}"
}
cmd_update() {
# Updates an installed daxlib package to a new version.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib update <package-id> --port <port> [--version <ver>]" >&2
exit 1
fi
if [[ -z "${OPT_PORT:-}" ]]; then
echo "--port is required for update" >&2
exit 1
fi
local version="${OPT_VERSION:-}"
if [[ -z "$version" ]]; then
version="$(resolve_latest_stable "$id")" || exit 1
fi
echo "Updating ${id} to v${version}..." >&2
local tmdl
tmdl="$(fetch_functions_tmdl "$id" "$version")" || exit 1
local temp
temp="$(write_temp_tmdl "$tmdl")"
trap "rm -f '$temp'" EXIT
run_daxlib_tom "update" "$OPT_PORT" "$id" "$temp"
}
cmd_remove() {
# Removes a daxlib package from a PBI Desktop model.
local id="${1:-}"
if [[ -z "$id" ]]; then
echo "Usage: daxlib remove <package-id> --port <port> [--fn name[,name]]" >&2
exit 1
fi
if [[ -z "${OPT_PORT:-}" ]]; then
echo "--port is required for remove" >&2
exit 1
fi
local tom_args=("remove" "$OPT_PORT" "$id")
if [[ ${#OPT_FUNCTIONS[@]} -gt 0 ]]; then
local fn_arg
fn_arg="$(IFS=,; echo "${OPT_FUNCTIONS[*]}")"
tom_args+=("--fn" "$fn_arg")
fi
run_daxlib_tom "${tom_args[@]}"
}
cmd_installed() {
# Lists all installed daxlib packages in a PBI Desktop model.
if [[ -z "${OPT_PORT:-}" ]]; then
echo "--port is required for installed" >&2
exit 1
fi
run_daxlib_tom "installed" "$OPT_PORT"
}
# #endregion
# #region Temp file helper
write_temp_tmdl() {
# Writes TMDL content to a temp file. On macOS, uses home directory
# (shared with Parallels VM) instead of /tmp/ (not shared).
local tmdl="$1"
local temp_dir
case "$(uname -s)" in
Darwin) temp_dir="$HOME/.daxlib-tmp" ;;
*) temp_dir="${TMPDIR:-/tmp}" ;;
esac
mkdir -p "$temp_dir"
local temp_path="${temp_dir}/daxlib_$$.tmdl"
echo "$tmdl" > "$temp_path"
echo "$temp_path"
}
# #endregion
# #region Arg Parsing
print_usage() {
cat >&2 <<'USAGE'
daxlib 0.1.0
CLI for DAX library packages from daxlib.org
USAGE:
daxlib <command> [options]
COMMANDS (standalone):
search <query> Search packages by name
info <package> Show package details
versions <package> List available versions
functions <package> List functions with signatures
download <package> Download functions.tmdl
COMMANDS (require PBI Desktop):
add <package> --port <p> Install package into model
update <package> --port <p> Update package in model
remove <package> --port <p> Remove package from model
installed --port <p> List installed packages
OPTIONS:
--port, -p <port> PBI Desktop AS port
--version, -v <ver> Package version (default: latest stable)
--fn, -f <name[,name]> Specific function(s) to add/remove/download
--output, -o <dir> Output directory for download
--json JSON output (where supported)
EXAMPLES:
daxlib search svg
daxlib info DaxLib.SVG
daxlib functions PowerofBI.IBCS
daxlib download DaxLib.SVG --fn "DaxLib.SVG.Element.Rect,DaxLib.SVG.SVG"
daxlib add DaxLib.SVG --port 54321
daxlib add DaxLib.SVG --port 54321 --fn "DaxLib.SVG.Element.Rect"
daxlib update PowerofBI.IBCS --port 54321 --version 0.11.0
daxlib remove DaxLib.SVG --port 54321 --fn "DaxLib.SVG.Color.Theme"
daxlib installed --port 54321
USAGE
}
# Global option variables
OPT_PORT=""
OPT_VERSION=""
OPT_OUTPUT=""
OPT_JSON=false
OPT_FUNCTIONS=()
parse_args() {
if [[ $# -eq 0 ]]; then
print_usage
exit 1
fi
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
print_usage
exit 0
fi
COMMAND="$1"
shift
local positional=()
while [[ $# -gt 0 ]]; do
case "$1" in
--port|-p)
shift
OPT_PORT="${1:-}"
[[ -n "$OPT_PORT" && "$OPT_PORT" =~ ^[0-9]+$ ]] || { echo "--port requires a number" >&2; exit 1; }
;;
--version|-v)
shift
OPT_VERSION="${1:-}"
;;
--fn|-f)
shift
IFS=',' read -ra OPT_FUNCTIONS <<< "${1:-}"
;;
--output|-o)
shift
OPT_OUTPUT="${1:-}"
;;
--json)
OPT_JSON=true
;;
--help|-h)
print_usage
exit 0
;;
-*)
echo "Unknown flag: $1" >&2
exit 1
;;
*)
positional+=("$1")
;;
esac
shift
done
POSITIONAL=("${positional[@]}")
}
# #endregion
# #region Main
COMMAND=""
POSITIONAL=()
parse_args "$@"
case "$COMMAND" in
search) cmd_search "${POSITIONAL[0]:-}" ;;
info) cmd_info "${POSITIONAL[0]:-}" ;;
versions) cmd_versions "${POSITIONAL[0]:-}" ;;
functions) cmd_functions "${POSITIONAL[0]:-}" ;;
download) cmd_download "${POSITIONAL[0]:-}" ;;
add) cmd_add "${POSITIONAL[0]:-}" ;;
update) cmd_update "${POSITIONAL[0]:-}" ;;
remove) cmd_remove "${POSITIONAL[0]:-}" ;;
installed) cmd_installed ;;
*)
echo "Unknown command: ${COMMAND}" >&2
echo >&2
print_usage
exit 1
;;
esac
# #endregion
Annotations and Extended Properties Reference
Annotations are key-value metadata pairs attachable to any TOM object (model, table, column, measure, partition, expression, role, etc.). They store metadata consumed by Power BI, Tabular Editor, DAX libraries, and custom tooling.
All examples assume $model is already connected (see SKILL.md section 3).
How Annotations Work
Annotations are strings. The key is the name; the value can be plain text, JSON, a number, or anything serializable as a string. They are invisible to report consumers and DAX queries; they only affect tooling behaviour.
Extended properties (extendedProperty) are a related but distinct concept; they use typed values (JSON, string) and are consumed by the engine rather than tooling. The most important extended property is ParameterMetadata on field parameters.
CRUD Operations
Create
$ann = New-Object Microsoft.AnalysisServices.Tabular.Annotation
$ann.Name = "MyApp_Category"
$ann.Value = "Revenue"
$model.Tables["Sales"].Measures["Total Revenue"].Annotations.Add($ann)
# Shorthand for simple annotations
$model.Tables["Sales"].Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{ Name = "TabularEditor_TableGroup"; Value = "02. Fact Tables" })
)Read
# All annotations on an object
foreach ($ann in $model.Tables["Sales"].Annotations) {
Write-Output " [$($ann.Name)] = $($ann.Value)"
}
# Specific annotation
$val = $model.Tables["Sales"].Annotations["TabularEditor_TableGroup"]?.ValueEnumerate all annotations across the model
# Model-level
foreach ($ann in $model.Annotations) { Write-Output "MODEL: [$($ann.Name)] = $($ann.Value)" }
# Table-level
foreach ($t in $model.Tables) {
foreach ($ann in $t.Annotations) { Write-Output "TABLE [$($t.Name)]: [$($ann.Name)] = $($ann.Value)" }
foreach ($c in $t.Columns) {
foreach ($ann in $c.Annotations) { Write-Output " COL [$($t.Name)].[$($c.Name)]: [$($ann.Name)] = $($ann.Value)" }
}
foreach ($m in $t.Measures) {
foreach ($ann in $m.Annotations) { Write-Output " MEAS [$($t.Name)].[$($m.Name)]: [$($ann.Name)] = $($ann.Value)" }
}
}Update
$model.Tables["Sales"].Annotations["TabularEditor_TableGroup"].Value = "01. Dimension Tables"Delete
$ann = $model.Tables["Sales"].Annotations["MyApp_Category"]
$model.Tables["Sales"].Annotations.Remove($ann)Standard Power BI Annotations
These are set automatically by Power BI Desktop and the service. Modifying them changes PBI behaviour.
| Annotation | Scope | Purpose | Example Value |
|---|---|---|---|
SummarizationSetBy | Column | Controls who set the summarize-by | Automatic, User |
PBI_FormatHint | Column, Measure | UI format hint | {"isGeneralNumber":true}, {"isText":true} |
UnderlyingDateTimeDataType | Column | Date subtype for the PBI engine | Date, DateTimeZone |
PBI_NavigationStepName | Table, Expression | Power Query navigation marker | Navigation |
PBI_ResultType | Table, Expression | Power Query result type | Table, Text, DateTime |
PBI_QueryOrder | Model | Display order of tables/expressions | JSON array of names |
PBI_QueryGroupOrder | Query Group | Order of query groups | Integer (0, 1, 2) |
PBI_Id | Role | Unique identifier for security roles | Hex GUID string |
__PBI_TimeIntelligenceEnabled | Model | Auto date/time feature toggle | 0 (disabled), 1 (enabled) |
Tabular Editor Annotations
Annotations consumed by Tabular Editor for organization and serialization.
Table Groups (TabularEditor_TableGroup)
Organizes tables into logical categories displayed in Tabular Editor's model tree. Does not affect Power BI reports or the engine; purely organizational for developers.
# Assign a table to a group
$model.Tables["Sales"].Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "TabularEditor_TableGroup"
Value = "02. Fact Tables"
})
)Common group naming convention (numbered prefix for ordering):
| Group | Tables |
|---|---|
00. Measure Tables | Dedicated measure tables (__Measures, etc.) |
01. Dimension Tables | Dimension/lookup tables |
02. Fact Tables | Fact/transaction tables |
03. Other Tables | Bridge tables, helper tables |
04. Selection Tables | Slicer/parameter selection tables |
05. Parameters | Field parameter tables |
06. Calculation Groups | Calculation group tables |
# Bulk-assign table groups by naming pattern
foreach ($t in $model.Tables) {
$group = if ($t.Name -match '^__') { "00. Measure Tables" }
elseif ($t.CalculationGroup -ne $null) { "06. Calculation Groups" }
elseif ($t.Name -match '^FP ') { "05. Parameters" }
elseif ($t.Name -match '^Z\d') { "06. Calculation Groups" }
else { $null }
if ($group) {
$existing = $t.Annotations["TabularEditor_TableGroup"]
if ($existing) { $existing.Value = $group }
else {
$t.Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "TabularEditor_TableGroup"; Value = $group
})
)
}
}
}Serialization Options (TabularEditor_SerializeOptions)
Model-level annotation controlling how Tabular Editor serializes the model to disk (BIM/TMDL). Only relevant when using Tabular Editor's Save to Folder; do not create manually.
# Read (informational; do not modify unless migrating TE settings)
$opts = $model.Annotations["TabularEditor_SerializeOptions"]?.Value
if ($opts) { $opts | ConvertFrom-Json | Format-List }Auto Date/Time (__PBI_TimeIntelligenceEnabled)
Controls whether Power BI auto-generates hidden date tables for every date column. Disabling this is best practice for production models; use explicit date tables instead.
# Check current state
$val = $model.Annotations["__PBI_TimeIntelligenceEnabled"]?.Value
Write-Output "Auto date/time: $(if ($val -eq '0') { 'DISABLED' } else { 'ENABLED' })"
# Disable auto date/time
$ann = $model.Annotations["__PBI_TimeIntelligenceEnabled"]
if ($ann) { $ann.Value = "0" }
else {
$model.Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "__PBI_TimeIntelligenceEnabled"; Value = "0"
})
)
}When disabled, remove the auto-generated LocalDateTable_* and DateTableTemplate_* tables that PBI may have already created, or they persist as orphaned metadata.
Field Parameters
Field parameters are calculated tables with a specific structure and an extendedProperty (not annotation) called ParameterMetadata that tells Power BI to treat the table as a field parameter slicer.
Structure
A field parameter table has three columns:
| Column | Purpose | Properties |
|---|---|---|
| Display name column | Shown in slicer; user-facing | sortByColumn = order column; relatedColumnDetails.groupByColumn = fields column |
| Fields column | Contains NAMEOF() references to actual measures/columns | Hidden; has extendedProperty ParameterMetadata |
| Order column | Sort order integer | Hidden |
The partition is a calculated table (DAX) with rows of tuples: {("Label", NAMEOF([Measure]), 0), ...}
Create a field parameter via TOM
# Define the measures to include
$measures = @(
@{ Label = "Revenue"; Ref = "[Total Revenue]"; Order = 0 },
@{ Label = "Cost"; Ref = "[Total Cost]"; Order = 1 },
@{ Label = "Margin"; Ref = "[Gross Margin]"; Order = 2 }
)
# Build DAX expression
$rows = ($measures | ForEach-Object {
" (`"$($_.Label)`", NAMEOF($($_.Ref)), $($_.Order))"
}) -join ",`n"
$daxExpr = "{`n$rows`n}"
# Create the table
$fpTable = New-Object Microsoft.AnalysisServices.Tabular.Table
$fpTable.Name = "FP - Metrics"
# Partition (calculated)
$partition = New-Object Microsoft.AnalysisServices.Tabular.Partition
$partition.Name = "FP - Metrics"
$partition.Source = New-Object Microsoft.AnalysisServices.Tabular.CalculatedPartitionSource
$partition.Source.Expression = $daxExpr
$fpTable.Partitions.Add($partition)
$model.Tables.Add($fpTable)
$model.SaveChanges()
# After SaveChanges, the engine auto-infers 3 CalculatedTableColumns.
# Refresh to populate them:
$model.RequestRefresh([Microsoft.AnalysisServices.Tabular.RefreshType]::Calculate)
$model.SaveChanges()
# Now configure the columns
$fpTable = $model.Tables["FP - Metrics"]
$nameCol = $fpTable.Columns | Where-Object { $_.SourceColumn -eq "[Value1]" }
$fieldsCol = $fpTable.Columns | Where-Object { $_.SourceColumn -eq "[Value2]" }
$orderCol = $fpTable.Columns | Where-Object { $_.SourceColumn -eq "[Value3]" }
# Rename columns
$nameCol.Name = "FP - Metrics"
$fieldsCol.Name = "FP - Metrics Fields"
$orderCol.Name = "FP - Metrics Order"
# Configure visibility
$fieldsCol.IsHidden = $true
$orderCol.IsHidden = $true
# Sort name column by order column
$nameCol.SortByColumn = $orderCol
# Set the groupBy relationship (name column grouped by fields column)
$nameCol.RelatedColumnDetails = New-Object Microsoft.AnalysisServices.Tabular.RelatedColumnDetails
$nameCol.RelatedColumnDetails.GroupByColumn = $fieldsCol
# Set the ParameterMetadata extended property on the fields column
$fieldsCol.SetExtendedProperty(
"ParameterMetadata",
'{"version":3,"kind":2}',
[Microsoft.AnalysisServices.Tabular.ExtendedPropertyType]::Json
)
# Sort fields column by order column too
$fieldsCol.SortByColumn = $orderCol
# Table group annotation (optional; for Tabular Editor organization)
$fpTable.Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "TabularEditor_TableGroup"; Value = "05. Parameters"
})
)
$model.SaveChanges()Read field parameter details
foreach ($t in $model.Tables) {
foreach ($c in $t.Columns) {
$ep = $c.ExtendedProperties | Where-Object { $_.Name -eq "ParameterMetadata" }
if ($ep) {
Write-Output "FIELD PARAM: [$($t.Name)] Fields column: [$($c.Name)]"
Write-Output " Metadata: $($ep.Value)"
# Find the display column (the one that groups by this column)
$displayCol = $t.Columns | Where-Object {
$_.RelatedColumnDetails -and $_.RelatedColumnDetails.GroupByColumn -eq $c
}
if ($displayCol) {
Write-Output " Display column: [$($displayCol.Name)]"
}
}
}
}ParameterMetadata values
| Field | Value | Meaning |
|---|---|---|
version | 3 | Metadata schema version |
kind | 2 | Field parameter (measures/columns); kind: 1 is a numeric range parameter |
DAX Library Annotations
Used by DAX library packages (reusable DAX function collections) to track package identity and version. Only relevant when managing DAX library functions.
| Annotation | Scope | Purpose | Example |
|---|---|---|---|
DAXLIB_PackageId | Function | Package identifier | DaxLib.FormatString |
DAXLIB_PackageVersion | Function | Semantic version | 1.0.0-beta |
Query Groups
Query groups organize named expressions (M queries/parameters) into folders in the Power Query editor. They are model-level objects with an ordering annotation.
Create
# Add a query group
$qg = New-Object Microsoft.AnalysisServices.Tabular.QueryGroup
$qg.Folder = "Parameters"
$model.QueryGroups.Add($qg)
# Set the display order annotation
$qg.Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "PBI_QueryGroupOrder"; Value = "0"
})
)
# Assign an expression to a query group (reference the QueryGroup object, not a string)
$targetGroup = $model.QueryGroups | Where-Object { $_.Folder -eq "Parameters" }
$model.Expressions["RangeStart"].QueryGroup = $targetGroupRead
foreach ($qg in $model.QueryGroups) {
$order = $qg.Annotations["PBI_QueryGroupOrder"]
$orderVal = if ($order) { $order.Value } else { "n/a" }
Write-Output "Query Group: [$($qg.Folder)] Order=$orderVal"
}
# Expressions and their groups
foreach ($e in $model.Expressions) {
$groupName = if ($e.QueryGroup) { $e.QueryGroup.Folder } else { "(ungrouped)" }
Write-Output " [$($e.Name)] -> $groupName"
}Custom Annotations for Tooling
Create application-specific annotations with a namespaced prefix to avoid collisions:
# Custom annotation with a namespace prefix
$model.Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "MyOrg_ModelOwner"; Value = "data-team@example.com"
})
)
# Store structured metadata as JSON
$model.Tables["Sales"].Annotations.Add(
(New-Object Microsoft.AnalysisServices.Tabular.Annotation -Property @{
Name = "MyOrg_DataLineage"
Value = '{"source":"ERP","schema":"dbo","lastVerified":"2026-01-15"}'
})
)Naming conventions:
- Prefix with an org/tool namespace to avoid collisions (
MyOrg_,DataGoblins_) - PBI reserves annotations starting with
PBI_or__PBI_ - Tabular Editor uses
TabularEditor_ - Do not overwrite annotations with unknown prefixes; they may be consumed by other tools
Best Practices
1. Disable auto date/time (__PBI_TimeIntelligenceEnabled = 0) on all production models; use explicit date tables 2. Set table groups (TabularEditor_TableGroup) for developer ergonomics in Tabular Editor 3. Use namespaced prefixes for custom annotations to avoid collisions 4. Treat PBI_ annotations as read-only unless deliberately overriding PBI behaviour 5. Field parameters require both the ParameterMetadata extended property AND the correct column relationships (sortByColumn, relatedColumnDetails.groupByColumn, hidden flags) 6. Query groups with PBI_QueryGroupOrder keep the Power Query editor organized; number them sequentially
Calendar Column Groups Reference
Calendar column groups define date hierarchies and time intelligence mappings declaratively on the model, telling the engine which columns represent Year, Quarter, Month, Week, Date, and other time units. This enables automatic time intelligence behaviour in Power BI visuals and DAX functions.
All examples assume $model is already connected (see SKILL.md section 3).
Compatibility: Calendar column groups require compatibility level 1604+ and are primarily used in Fabric / Power BI service semantic models. PBI Desktop support may be limited depending on the version.
Concepts
Calendar vs Column Group
A calendar belongs to a specific table (typically a date table) and contains one or more column groups. Each column group maps a physical column to a time unit.
Time Units
Time units are case-sensitive enum values. Common mistakes: using Day instead of Date, pluralizing (Years), or incorrect casing.
| Time Unit | Description | Example Value |
|---|---|---|
Year | Complete year | 2024 |
Quarter | Complete quarter (includes year) | Q3 2024 |
QuarterOfYear | Quarter position (1-4) | 3 |
Month | Complete month (includes year) | January 2024 |
MonthOfYear | Month name/number without year | January, 6 |
MonthOfQuarter | Month position within quarter | 2 |
Week | Complete week (includes year) | 2024-W49 |
WeekOfYear | Week number without year | 49 |
Date | Specific date | 2024-01-15 |
DayOfYear | Day position in year (1-366) | 241 |
DayOfMonth | Day of month (1-31) | 23 |
DayOfWeek | Day of week (1-7) | 4 |
Unknown | Time-related but not a standard unit | Used for flags like IsWeekend |
Complete vs Partial Units
- Complete units uniquely identify a period:
Year,Quarter,Month,Week,Date. These must include enough context (e.g. year) to be unambiguous. - Partial units are positions within a larger period:
QuarterOfYear,MonthOfYear,WeekOfYear,DayOfMonth, etc. Use these for labels and slicers, not for hierarchical rollups.
Do not confuse them: "December 2024" maps to Month (complete); "December" maps to MonthOfYear (partial).
Primary vs Associated Columns
Each column group has one primary column (the sort/key column) and optionally associated columns (display labels). If column A is sorted by column B (via SortByColumn), then B should be the primary and A an associated column.
Example: Year Month Number (int, primary) + Year Month (text, associated) + Year Month Short (text, associated).
Time-Related Groups
Columns that are time-aware but do not represent a standard unit (e.g. RelativeMonth with values "Current"/"Previous", IsWeekend, Season) belong in a single time-related group. Do not create separate groups for each; the engine keys by time unit and rejects duplicates.
Reading Calendar Column Groups
# Check if the model has calendars defined
foreach ($t in $model.Tables) {
if ($t.Calendars -and $t.Calendars.Count -gt 0) {
foreach ($cal in $t.Calendars) {
Write-Output "CALENDAR: [$($cal.Name)] on table [$($t.Name)]"
foreach ($cg in $cal.CalendarColumnGroups) {
$primary = $cg.PrimaryColumn.Name
$assoc = ($cg.AssociatedColumns | ForEach-Object { $_.Name }) -join ", "
Write-Output " $($cg.TimeUnit): Primary=[$primary] Associated=[$assoc]"
}
if ($cal.TimeRelatedGroup) {
$cols = ($cal.TimeRelatedGroup.Columns | ForEach-Object { $_.Name }) -join ", "
Write-Output " TimeRelated: [$cols]"
}
}
}
}Creating a Gregorian Calendar
Standard Year > Quarter > Month > Date hierarchy:
$dateTable = $model.Tables["Date"]
# Create the calendar object
$cal = New-Object Microsoft.AnalysisServices.Tabular.Calendar
$cal.Name = "Gregorian Calendar"
# Year group
$yearGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$yearGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Year
$yearGroup.PrimaryColumn = $dateTable.Columns["Year"]
$cal.CalendarColumnGroups.Add($yearGroup)
# Quarter group (primary = sort key, associated = display label)
$qtrGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$qtrGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Quarter
$qtrGroup.PrimaryColumn = $dateTable.Columns["Year Quarter Number"]
$qtrGroup.AssociatedColumns.Add($dateTable.Columns["Year Quarter"])
$cal.CalendarColumnGroups.Add($qtrGroup)
# Month group
$monthGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$monthGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Month
$monthGroup.PrimaryColumn = $dateTable.Columns["Year Month Number"]
$monthGroup.AssociatedColumns.Add($dateTable.Columns["Year Month"])
$cal.CalendarColumnGroups.Add($monthGroup)
# Date group
$dateGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$dateGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Date
$dateGroup.PrimaryColumn = $dateTable.Columns["Date"]
$cal.CalendarColumnGroups.Add($dateGroup)
$dateTable.Calendars.Add($cal)
$model.SaveChanges()Creating a Fiscal Calendar
When the fiscal year differs from the calendar year:
$dateTable = $model.Tables["Date"]
$fiscal = New-Object Microsoft.AnalysisServices.Tabular.Calendar
$fiscal.Name = "Fiscal Calendar"
# Fiscal year
$fyGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$fyGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Year
$fyGroup.PrimaryColumn = $dateTable.Columns["Fiscal Year Number"]
$fyGroup.AssociatedColumns.Add($dateTable.Columns["Fiscal Year Name"])
$fiscal.CalendarColumnGroups.Add($fyGroup)
# Fiscal month (complete; includes fiscal year context)
$fmGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$fmGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Month
$fmGroup.PrimaryColumn = $dateTable.Columns["Fiscal Year Month Number"]
$fmGroup.AssociatedColumns.Add($dateTable.Columns["Fiscal Year Month"])
$fiscal.CalendarColumnGroups.Add($fmGroup)
# Fiscal month of year (partial; for labels/slicers)
$fmoyGroup = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$fmoyGroup.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::MonthOfYear
$fmoyGroup.PrimaryColumn = $dateTable.Columns["Fiscal Month Number of Year"]
$fmoyGroup.AssociatedColumns.Add($dateTable.Columns["Fiscal Month Name"])
$fiscal.CalendarColumnGroups.Add($fmoyGroup)
# Time-related columns (single group for all non-standard time columns)
$trGroup = New-Object Microsoft.AnalysisServices.Tabular.TimeRelatedGroup
$trGroup.Columns.Add($dateTable.Columns["RelativeMonth"])
$trGroup.Columns.Add($dateTable.Columns["Season"])
$fiscal.TimeRelatedGroup = $trGroup
$dateTable.Calendars.Add($fiscal)
$model.SaveChanges()ISO Week-Based Calendar (4-4-5)
For week-based calendars, map Period to the Month time unit (same hierarchical position). Always pair ISO weeks with ISO year, not Gregorian year.
$dateTable = $model.Tables["ISO Date"]
$iso = New-Object Microsoft.AnalysisServices.Tabular.Calendar
$iso.Name = "ISO 4-4-5 Calendar"
# ISO Year
$isoYear = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$isoYear.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Year
$isoYear.PrimaryColumn = $dateTable.Columns["ISO Year"]
$iso.CalendarColumnGroups.Add($isoYear)
# Period mapped to Month (same hierarchical position)
$period = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$period.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Month
$period.PrimaryColumn = $dateTable.Columns["Year-Period"]
$iso.CalendarColumnGroups.Add($period)
# Week
$week = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$week.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Week
$week.PrimaryColumn = $dateTable.Columns["Year-Week"]
$iso.CalendarColumnGroups.Add($week)
# Date
$dateGrp = New-Object Microsoft.AnalysisServices.Tabular.CalendarColumnGroup
$dateGrp.TimeUnit = [Microsoft.AnalysisServices.Tabular.TimeUnit]::Date
$dateGrp.PrimaryColumn = $dateTable.Columns["Date"]
$iso.CalendarColumnGroups.Add($dateGrp)
$dateTable.Calendars.Add($iso)
$model.SaveChanges()Rules and Constraints
1. Calendar names must be unique across the entire model, not just within a table 2. Each calendar uses columns from only its host table 3. Do not repeat a time unit within the same calendar 4. A column must map to the same time unit in every calendar that includes it 5. Do not use the same physical column more than once in the same calendar 6. Only one time-related group per calendar; combine all time-related columns into it 7. Build hierarchies where each level subdivides exactly into the level above 8. For complete units, include year context (e.g. "January 2024" for Month, not "January") 9. For ISO/week-based calendars, always use ISO year with ISO week numbers
Effect on Time Intelligence
With calendar column groups defined, standard DAX time intelligence functions automatically adapt:
DATESYTDuses the calendar's year definitionDATESMTDreturns month-to-date (or period-to-date for week-based calendars where Period maps to Month)DATESWTDreturns week-to-dateSAMEPERIODLASTYEARshifts based on the calendar hierarchyDATEADDrespects the defined periods and supports Extension/Truncation parameters for uneven period lengths
DAX Expression Locations in a Semantic Model
DAX appears in many places across a semantic model, each with different rules for what is valid. Understanding where DAX lives and how to read/write each location via TOM is essential for model modification.
All examples assume $model is already connected (see SKILL.md section 3).
Expression Types at a Glance
| Location | TOM Property | Returns | Context | Refresh Needed |
|---|---|---|---|---|
| Measure expression | Measure.Expression | Scalar | Filter context from visuals | No |
| Calculated column | CalculatedColumn.Expression | Scalar (per row) | Row context of host table | calculate |
| Calculated table | CalculatedPartitionSource.Expression | Table | No context (model-level) | calculate |
| Calculation item | CalculationItem.Expression | Depends on SELECTEDMEASURE() | Filter context | No |
| Format string (static) | Measure.FormatString | n/a (format pattern) | n/a | No |
| Format string (dynamic) | FormatStringDefinition.Expression | String | Filter context | No |
| Detail rows | DetailRowsDefinition.Expression | Table | Filter context from drillthrough | No |
| RLS filter | TablePermission.FilterExpression | Boolean | Row context of filtered table | No |
| DAX UDF | UserDefinedFunction.Expression | Scalar or Table | Depends on call site | No |
| KPI status/target/trend | KPI.StatusExpression, .TargetExpression, .TrendExpression | Scalar | Filter context | No |
Measure Expressions
The most common DAX location. Evaluated at query time in filter context; never materialized.
# Read
$expr = $model.Tables["Sales"].Measures["Total Revenue"].Expression
# Write
$model.Tables["Sales"].Measures["Total Revenue"].Expression = "SUM('Sales'[Amount])"Rules: Must return a scalar value. Can reference columns (fully qualified: 'Table'[Column]), other measures (unqualified: [Measure]), and use any DAX function. Cannot use row context functions like RELATED() without an iterator.
Calculated Column Expressions
Evaluated row-by-row in the host table's row context. Materialized in storage after refresh.
# Read
$cc = $model.Tables["Customers"].Columns | Where-Object { $_ -is [Microsoft.AnalysisServices.Tabular.CalculatedColumn] }
foreach ($c in $cc) { Write-Output "[$($c.Name)] = $($c.Expression)" }
# Write
$col = [Microsoft.AnalysisServices.Tabular.CalculatedColumn]$model.Tables["Customers"].Columns["Full Name"]
$col.Expression = "'Customers'[FirstName] & "" "" & 'Customers'[LastName]"Rules: Has implicit row context for the host table. Can use RELATED() to access columns from related tables (following relationships). Cannot use CALCULATE() without explicit context transition. Must return a scalar.
Calculated Table Expressions
Evaluated at model level with no implicit filter or row context. Returns a full table.
# Read
foreach ($t in $model.Tables) {
foreach ($p in $t.Partitions) {
if ($p.Source -is [Microsoft.AnalysisServices.Tabular.CalculatedPartitionSource]) {
Write-Output "[$($t.Name)] = $([Microsoft.AnalysisServices.Tabular.CalculatedPartitionSource]$p.Source).Expression"
}
}
}
# Write
$partition = $model.Tables["Date"].Partitions["Date"]
$src = [Microsoft.AnalysisServices.Tabular.CalculatedPartitionSource]$partition.Source
$src.Expression = "CALENDAR(DATE(2020,1,1), DATE(2030,12,31))"Rules: Must return a table. Common functions: CALENDAR, CALENDARAUTO, DATATABLE, GENERATESERIES, SELECTCOLUMNS, UNION, ROW (single-row table), literal table constructors {(...), ...}. Field parameters use literal tuple constructors with NAMEOF().
Calculation Item Expressions
Part of calculation groups. Use SELECTEDMEASURE() to reference whatever measure the calc group modifies.
# Read
$cgTable = $model.Tables | Where-Object { $_.CalculationGroup -ne $null } | Select-Object -First 1
foreach ($item in $cgTable.CalculationGroup.CalculationItems) {
Write-Output "[$($item.Name)] = $($item.Expression)"
}
# Write
$item = $cgTable.CalculationGroup.CalculationItems["YTD"]
$item.Expression = "CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))"Rules: Must use SELECTEDMEASURE() to reference the base measure being modified. Can also use SELECTEDMEASURENAME(), SELECTEDMEASUREFORMATSTRING(), ISSELECTEDMEASURE(). Runs in the filter context of the visual.
Format String Expressions (Dynamic)
DAX expression that returns a format string. Requires compatibility level 1470+.
# Read
$m = $model.Tables["Sales"].Measures["Dynamic Metric"]
if ($m.FormatStringDefinition -and $m.FormatStringDefinition.Expression) {
Write-Output "Dynamic format: $($m.FormatStringDefinition.Expression)"
}
# Write
$fsd = New-Object Microsoft.AnalysisServices.Tabular.FormatStringDefinition
$fsd.Expression = 'IF(SELECTEDVALUE(''Metric''[Type]) = "Pct", "0.0%", "$#,0")'
$m.FormatStringDefinition = $fsdRules: Must return a string that is a valid format pattern. Evaluated in filter context. Common patterns: switching format based on a slicer selection, or using SELECTEDMEASUREFORMATSTRING() in calculation items.
Calculation items can also have their own FormatStringDefinition:
$item = $cgTable.CalculationGroup.CalculationItems["YoY Change"]
$itemFsd = New-Object Microsoft.AnalysisServices.Tabular.FormatStringDefinition
$itemFsd.Expression = '"0.0%"'
$item.FormatStringDefinition = $itemFsdStatic Format Strings
Not DAX; a .NET format pattern string. Set on measures and columns.
$m.FormatString = "#,0.00" # two decimal places
$m.FormatString = "$#,0" # currency
$m.FormatString = "0.0%" # percentage
$m.FormatString = "yyyy-MM-dd" # dateDetail Rows Expressions
Defines what rows appear when a user drills through a measure. Must return a table.
# Read
$m = $model.Tables["Sales"].Measures["Total Revenue"]
if ($m.DetailRowsDefinition) {
Write-Output "Detail rows: $($m.DetailRowsDefinition.Expression)"
}
# Write
$drd = New-Object Microsoft.AnalysisServices.Tabular.DetailRowsDefinition
$drd.Expression = 'SELECTCOLUMNS(''Sales'', "Product", RELATED(''Products''[Name]), "Amount", ''Sales''[Amount], "Date", ''Sales''[OrderDate])'
$m.DetailRowsDefinition = $drdRules: Must return a table. Evaluated in the filter context of the drillthrough action. Can use RELATED() since it iterates over a fact table.
Tables can also have a default detail rows expression:
$table = $model.Tables["Sales"]
$tableDrd = New-Object Microsoft.AnalysisServices.Tabular.DetailRowsDefinition
$tableDrd.Expression = 'SELECTCOLUMNS(''Sales'', "Product", RELATED(''Products''[Name]), "Amount", ''Sales''[Amount])'
$table.DefaultDetailRowsDefinition = $tableDrdRLS Filter Expressions
Row-Level Security expressions filter rows visible to a role. Evaluated in row context of the target table.
# Read
foreach ($role in $model.Roles) {
foreach ($tp in $role.TablePermissions) {
if ($tp.FilterExpression) {
Write-Output "Role [$($role.Name)] on [$($tp.Table.Name)]: $($tp.FilterExpression)"
}
}
}
# Write
$tp = $model.Roles["Region Filter"].TablePermissions | Where-Object { $_.Table.Name -eq "Sales" }
$tp.FilterExpression = "'Sales'[Region] = USERPRINCIPALNAME()"Rules: Must return a boolean. Has implicit row context for the filtered table. Can use USERNAME(), USERPRINCIPALNAME(), CUSTOMDATA(). Can reference other tables via RELATED() or LOOKUPVALUE(). Cannot use measures directly.
DAX User Defined Function Expressions
Reusable parameterized DAX callable from measures. Requires compatibility level 1702+.
# Read
if ($model.PSObject.Properties['UserDefinedFunctions']) {
foreach ($udf in $model.UserDefinedFunctions) {
Write-Output "[$($udf.Name)] = $($udf.Expression)"
}
}
# Write (see tom-object-types.md for full UDF creation including parameters)
$udf = $model.UserDefinedFunctions["PriorYearValue"]
$udf.Expression = '(expression : Scalar Variant Expr, dateColumn : AnyRef) => CALCULATE(expression, SAMEPERIODLASTYEAR(dateColumn))'Rules: Function body follows the (params) => body syntax. Parameters have type hints (Scalar, Table, AnyRef) and evaluation modes (Val, Expr). See tom-object-types.md for the full parameter type reference.
KPI Expressions
Legacy KPI objects on measures. Three expression slots:
$kpi = $model.Tables["Sales"].Measures["Revenue vs Target"].KPI
# Target: scalar expression for the goal
$kpi.TargetExpression = "1.0"
# Status: returns -1, 0, or 1 (bad, neutral, good)
$kpi.StatusExpression = 'IF([Revenue vs Target] >= 1, 1, IF([Revenue vs Target] >= 0.8, 0, -1))'
# Trend: optional; returns -1, 0, or 1
$kpi.TrendExpression = 'IF([Revenue vs Target] > [Revenue vs Target PY], 1, -1)'Note: KPIs are a legacy SSAS feature. Power BI visuals ignore them; use conditional formatting measures instead.
Summary: Validation Patterns
Before saving any DAX expression, test it against the live model:
| Expression Type | Validation Wrapper |
|---|---|
| Measure | EVALUATE ROW("@Test", <expr>) |
| Calculated column | EVALUATE ROW("@Test", CALCULATE(<expr>)) (approximate) |
| Calculated table | EVALUATE <expr> or EVALUATE ROW("@Count", COUNTROWS(<expr>)) |
| RLS filter | EVALUATE CALCULATETABLE(ROW("@OK", 1), <expr>) |
| Format string | EVALUATE ROW("@Fmt", <expr>) -- check it returns a string |
| Detail rows | EVALUATE <expr> -- check it returns a table |
DAX Pitfalls: Deprecated, Not Recommended, and Non-Existent Functions
Reference for avoiding common DAX mistakes; particularly those that AI agents generate from training data in other languages (SQL, Python, Excel, M).
Functions Marked "Not Recommended" on dax.guide
These exist and execute without error but should be replaced with modern alternatives.
| Function | Status | Alternative | Why |
|---|---|---|---|
EARLIER(col) | Not recommended | VAR | Confusing row context semantics. Capture the value in a variable before entering a nested row context |
EARLIEST(col) | Not recommended | VAR | Same as EARLIER but retrieves from the outermost row context. Even more confusing |
SUMMARIZE Name/Expression params | Deprecated | SUMMARIZECOLUMNS or ADDCOLUMNS(SUMMARIZE(...)) | Extension columns in SUMMARIZE use clustering semantics that differ from what most users expect. Use SUMMARIZE only for grouping (no added columns) |
Functions That Do Not Exist in DAX
Agents frequently hallucinate these from SQL, Python, Excel VBA, or M training data. They will cause immediate syntax errors.
| Hallucinated Function | Language Source | DAX Equivalent |
|---|---|---|
TRY ... CATCH | Python, C#, JavaScript | IFERROR(expr, fallback) |
TRY ... OTHERWISE | Power Query M | IFERROR(expr, fallback) |
ISNULL(expr) | SQL Server | ISBLANK(expr) ; DAX has no null concept, only BLANK |
IIF(cond, true, false) | SQL Server, VBA | IF(cond, true, false) |
NZ(expr, default) | Access VBA | IF(ISBLANK(expr), default, expr) or COALESCE(expr, default) |
COALESCE(a, b, ...) | SQL | COALESCE(a, b, ...) actually exists in DAX (added ~2020). But agents sometimes use SQL-style ISNULL or IFNULL instead |
IFNULL(expr, default) | MySQL, SQLite | COALESCE(expr, default) or IF(ISBLANK(expr), default, expr) |
NVL(expr, default) | Oracle | COALESCE(expr, default) |
CAST(expr AS type) | SQL | CONVERT(expr, type) or implicit conversion |
SUBSTRING(text, start, len) | SQL | MID(text, start, len) |
CONCAT(a, b) | SQL | a & b (ampersand operator) or CONCATENATE(a, b) |
LEN(text) | Excel, SQL | LEN(text) actually exists in DAX |
TRIM(text) | Excel, SQL | TRIM(text) actually exists in DAX |
GETDATE() | SQL Server | TODAY() or NOW() |
CURDATE() | MySQL | TODAY() |
DATEDIFF(start, end, unit) | SQL | DATEDIFF(date1, date2, interval) exists but parameter order and interval names differ from SQL |
GROUP BY | SQL | Not a function; DAX has no GROUP BY clause. Use SUMMARIZECOLUMNS or GROUPBY |
HAVING | SQL | Not a function; use FILTER on a summarized table |
JOIN / LEFT JOIN | SQL | Not a function; DAX uses relationships + RELATED/RELATEDTABLE, or NATURALINNERJOIN/NATURALLEFTOUTERJOIN |
PIVOT / UNPIVOT | SQL | Not functions; do reshaping in Power Query (M), not DAX |
COLLECT / REDUCE | Python, JavaScript | Not functions; use iterators like SUMX, MAXX, CONCATENATEX |
MAP / APPLY | Python, R | Not functions; use ADDCOLUMNS or iterator functions |
LAMBDA | Excel, Python | Not a function; use VAR for intermediate expressions |
STRING(expr) | Various | FORMAT(expr, format_string) or CONVERT(expr, STRING) |
TOSTRING(expr) | JavaScript | FORMAT(expr, format_string) or CONVERT(expr, STRING) |
TOINT(expr) / TOFLOAT(expr) | Python | INT(expr) or CONVERT(expr, INTEGER) / CONVERT(expr, DOUBLE) |
POWER(base, exp) | Excel | POWER(base, exp) actually exists in DAX |
ROUND(num, digits) | Various | ROUND(num, digits) actually exists in DAX |
ARRAY(...) | Various | Not a function; use table constructors {(val1, val2), ...} |
PRINT / CONSOLE.LOG | Various | Not functions; DAX has no output/debug statements |
Common DAX Syntax Mistakes from Other Languages
| Mistake | Correct DAX |
|---|---|
-- comment | // comment (double dash is not a comment in DAX) |
= 'text' (single-quoted string) | = "text" (double-quoted; single quotes are for table names) |
table.column (dot notation) | 'Table'[Column] (bracket notation with single-quoted table) |
[Column] without table (in measures) | 'Table'[Column] (always fully qualify column references) |
variable = value (assignment) | VAR variable = value RETURN ... (VAR/RETURN pattern required) |
SELECT ... FROM | EVALUATE ... (DAX queries use EVALUATE, not SELECT; SELECT is for DMV only) |
WHERE condition | CALCULATETABLE(table, condition) or FILTER(table, condition) |
ORDER BY col ASC outside EVALUATE | ORDER BY must follow an EVALUATE expression in a DAX query |
!= (not equal) | <> (DAX uses diamond operator for not-equal) |
&& / ` | |
true / false (lowercase) | TRUE() / FALSE() (function syntax with parens) |
null | BLANK() (DAX has no null literal) |
Common Correctness Traps
Patterns that execute without error but produce wrong results. These account for the majority of DAX debugging questions on community forums.
| Trap | What happens | Fix |
|---|---|---|
CALCULATE(expr, ALL('Table')) | Removes ALL filters including SUMMARIZECOLUMNS grouping; every row shows the same value | Use ALL('Table'[Column]) or REMOVEFILTERS('Table'[Column]) to target specific columns |
CALCULATE(expr, 'T'[Col] = "X") | Replaces existing filter on that column (doesn't AND) | Use KEEPFILTERS('T'[Col] = "X") to intersect with existing filters |
VAR _x = SUM(...) RETURN CALCULATE(_x, filter) | VAR is evaluated once at definition; CALCULATE cannot re-evaluate it | Move the aggregation inside CALCULATE: CALCULATE(SUM(...), filter) |
SUM(A) / SUM(B) in grand total | Divides global sums, not sum of row-level ratios; total appears "wrong" | This is mathematically correct (weighted average); if sum-of-ratios is needed, use SUMX(VALUES(Dim[Key]), [Ratio]) |
DATEADD(scalar_date, -1, MONTH) | DATEADD requires a date table, not a scalar value | Use DATEADD('Date'[Date], -1, MONTH) with a date column reference |
CALCULATE(expr, FILTER('Sales', condition)) | Filters the expanded table (includes related tables via relationships), not just Sales. Causes both incorrect results (intersection with related tables) and severe performance degradation (117x slower in SQLBI benchmarks) | Use column predicates: CALCULATE(expr, 'Sales'[Col] = "X"). For complex conditions use KEEPFILTERS(condition). Only use FILTER on a table when the condition spans multiple columns that can't be expressed as separate filter arguments |
ALLSELECTED() with no arguments | Removes all grouping filters from SUMMARIZECOLUMNS; not just slicer filters | Always specify the table or column: ALLSELECTED('Date') |
| Deeply nested IF / repeated measure in IF branches | Each IF branch may be evaluated independently; referencing the same measure in both branches causes double evaluation | Store the measure in a VAR before the IF; use SWITCH(TRUE(), ...) for multi-condition logic. Place VARs inside conditional branches if they're only used there (preserves short-circuit optimization) |
| Implicit measures (auto-sum) | Numeric columns auto-aggregate in visuals; bypasses explicit measure logic | Disable via model property or set SummarizeBy = None on columns that shouldn't auto-aggregate |
CALCULATE Modifiers Reference
Functions used as filter arguments inside CALCULATE / CALCULATETABLE. They modify the filter context rather than returning values. Misusing them produces the most common visual symptoms reported on community forums.
Filter Removal
| Modifier | What it removes | Visual symptom if misused |
|---|---|---|
ALL('Table') | All filters on the table, including the visual's grouping columns | Every row in the matrix/table shows the same value (the ungrouped total). The #1 reported DAX bug on community forums |
ALL('Table'[Col]) | Filters on that one column only; preserves grouping and other column filters | Correct per-row values but grand total ignores one dimension (e.g. region total that ignores region) |
ALL('T'[C1], 'T'[C2]) | Filters on specific columns only | Same as above but for multiple columns |
ALLEXCEPT('Table', 'T'[KeepCol]) | All filters except the named columns | Rows look correct but subtotals may differ from expected; confusing when the "kept" column isn't the one in the visual |
ALLSELECTED('Table') | Grouping filters from SUMMARIZECOLUMNS; preserves slicer/page filters | If table is omitted (ALLSELECTED() with no args): same-value-every-row because ALL grouping is removed, not just the intended dimension |
REMOVEFILTERS('Table') | Same as ALL('Table') | Same same-value symptom. Prefer REMOVEFILTERS over ALL for clarity; signals intent to remove filters rather than to materialize all rows |
REMOVEFILTERS('T'[Col]) | Same as ALL('T'[Col]) | Same column-level removal |
Common mistake: Using ALL('Sales') as a denominator for % of total when the visual groups by 'Sales'[Region]. The ALL removes the Region grouping, so the denominator is correct (grand total) but if accidentally applied to the numerator too, every row shows the grand total. Fix: use ALL('Sales'[Region]) or ALLSELECTED('Sales'[Region]).
Filter Intersection
| Modifier | What it does | Visual symptom if missing |
|---|---|---|
KEEPFILTERS('T'[Col] = "X") | Intersects the new filter with existing filters on that column | Without KEEPFILTERS: the filter replaces existing filters. If the visual already filters to Region = "East" and the measure does CALCULATE(expr, 'T'[Region] = "West"), it overrides "East" with "West" instead of returning BLANK (no intersection). The user sees "West" values appearing in "East" rows |
KEEPFILTERS(table_expr) | Same intersection behavior for table expressions | Same override problem with table-level filters |
Common mistake: Building a "Red Sales" measure as CALCULATE([Sales], 'Product'[Color] = "Red"). When the visual has a slicer on Color = "Blue", the measure still shows Red because the filter replaces the slicer. With KEEPFILTERS('Product'[Color] = "Red"), it correctly returns BLANK when Blue is selected (intersection of Red and Blue is empty).
Relationship Modifiers
| Modifier | What it does | Visual symptom if wrong |
|---|---|---|
USERELATIONSHIP('Fact'[ShipDate], 'Date'[Date]) | Activates an inactive relationship for this calculation | Without it: measure uses the active relationship (e.g. order date) when the user expects ship date. Values look plausible but are offset by the order-to-ship lag |
CROSSFILTER('T1'[Col], 'T2'[Col], Both) | Changes cross-filter direction to bidirectional for this calculation | Without it: dimension slicer doesn't filter the fact table (appears to "do nothing"). Common with bridge tables in many-to-many patterns |
USERELATIONSHIP limitation: Cannot be used when the target table has Row-level security (RLS). Use TREATAS as a workaround.
Virtual Relationships
| Function | What it does | When to use |
|---|---|---|
TREATAS(table_expr, 'T'[Col]) | Applies values from a table expression as a filter on the target column, as if a relationship existed | When tables are unrelated or USERELATIONSHIP is blocked by RLS. Also useful for disconnected slicer tables (parameter tables that drive measure behavior without a physical relationship) |
Common symptom without TREATAS: A slicer on a disconnected table "doesn't filter anything." TREATAS bridges the gap by projecting the slicer's values onto the target column.
Key Rules
- Filter arguments in CALCULATE are evaluated in the original context before being applied
- Multiple filter arguments AND together (each narrows independently)
ALL/REMOVEFILTERSexecute before explicit filter arguments in CALCULATE's evaluation order- Without KEEPFILTERS, a filter argument on column X replaces any existing filter on column X
- USERELATIONSHIP and CROSSFILTER override model-level relationship settings for the calculation only
- Innermost CALCULATE wins when nested expressions contain conflicting modifiers
BLANK vs NULL
DAX has no concept of NULL. The equivalent is BLANK, which behaves differently from SQL NULL:
BLANK() + 1returns1(not BLANK); SQL NULL + 1 returns NULLBLANK() & "text"returns"text"; SQL NULL || 'text' returns NULLIF(BLANK(), "yes", "no")returns"no"(BLANK is falsy)BLANK() = BLANK()returnsTRUE; SQL NULL = NULL returns NULL (unknown)
Use ISBLANK() to test, not ISNULL() (which does not exist).
DAX Library Packages (daxlib.org)
daxlib.org is an open-source package registry for DAX User-Defined Functions (UDFs), maintained by SQLBI. It provides reusable, model-independent DAX function libraries installable into any semantic model with compatibility level 1702+.
GitHub: github.com/daxlib/daxlib (all published packages).
Package Structure
Each package in the registry follows this layout:
packages/{first-letter-lowercase}/{package.id-lowercase}/{version}/
manifest.daxlib # JSON metadata (id, version, authors, description, tags)
lib/functions.tmdl # DAX UDF definitions in TMDL format
README.md # Optional documentation
icon.png # Optional iconDirectory names are all-lowercase on GitHub even though the id in the manifest is PascalCase (e.g. directory daxlib.svg for package DaxLib.SVG).
Raw download URLs:
https://raw.githubusercontent.com/daxlib/daxlib/main/packages/{letter}/{id-lowercase}/{version}/manifest.daxlib
https://raw.githubusercontent.com/daxlib/daxlib/main/packages/{letter}/{id-lowercase}/{version}/lib/functions.tmdlVersion listing via GitHub API:
GET https://api.github.com/repos/daxlib/daxlib/contents/packages/{letter}/{id-lowercase}TMDL Function Format
Functions use standard DAX UDF syntax with package-tracking annotations:
/// JSDoc description
/// @param {type} paramName - description
/// @returns {type} description
function 'PackageId.FunctionName' =
(
param1: STRING,
param2: NUMERIC VAL,
exprParam: SCALAR EXPR,
refParam: ANYREF EXPR
) =>
VAR _x = ...
RETURN result
annotation DAXLIB_PackageId = PackageId
annotation DAXLIB_PackageVersion = X.Y.ZParameter modes: VAL (eager; evaluated once before call) and EXPR (lazy; evaluated inside the function body in the function's context). EXPR is required for measure references and context-sensitive expressions.
Tracking Installed Packages
Every function carries two annotations:
| Annotation | Purpose |
|---|---|
DAXLIB_PackageId | Which package the function belongs to |
DAXLIB_PackageVersion | Which version was installed |
These annotations enable listing, updating, and removing packages programmatically by scanning $model.UserDefinedFunctions for matching annotation values.
daxlib CLI
CLI for managing daxlib packages. Standalone commands (search, browse, download) work on any platform. Model operations (add, update, remove) require PBI Desktop; on macOS these route through Parallels automatically.
Browsing the Registry (standalone)
daxlib search <query> # Search packages by name (substring match)
daxlib info <package> # Package manifest: authors, description, tags, function count
daxlib info <package> -v <version> # Specific version info
daxlib versions <package> # All published versions (newest first; pre-release marked)
daxlib functions <package> # List every function with parameter signatures
daxlib functions <package> -v <ver> # Functions for a specific versionUses gh CLI (authenticated; 5000 req/hr with --cache 1h). Falls back to curl if gh unavailable.
Downloading TMDL (standalone)
daxlib download <package> # Latest stable; writes <id>.functions.tmdl
daxlib download <package> -v <version> # Specific version
daxlib download <package> --fn "Name1,Name2" # Filter to specific functions only
daxlib download <package> --fn "Element.Rect" # Suffix match (matches DaxLib.SVG.Element.Rect)
daxlib download <package> -o /path/to/dir # Output directory (default: cwd)Installing Packages (requires PBI Desktop)
daxlib add <package> --port <port> # Install full package (latest stable)
daxlib add <package> --port <port> -v <version> # Specific version
daxlib add <package> --port <port> --fn "Name" # Install single function onlySkips functions that already exist (by name). Each installed function gets DAXLIB_PackageId and DAXLIB_PackageVersion annotations.
CL upgrade warning: DAX UDFs require compatibility level 1702+. If the model is below 1702,daxlib addwill upgrade the CL automatically. This is irreversible; older tools that don't support CL 1702 won't open the model afterward. Always confirm with the user before runningdaxlib addon a model below CL 1702.
Updating Packages
daxlib update <package> --port <port> # Update to latest stable
daxlib update <package> --port <port> -v <ver> # Update to specific versionRemoves all existing functions for the package (matched by DAXLIB_PackageId annotation), then installs the new version. User-created functions are never touched.
Removing Packages or Functions
daxlib remove <package> --port <port> # Remove entire package
daxlib remove <package> --port <port> --fn "Name" # Remove specific function(s) onlyPackage removal uses annotation matching; only functions with DAXLIB_PackageId matching the package are removed. Function-by-name removal (--fn) uses exact match or packageId.name prefix match.
Listing Installed Packages
daxlib installed --port <port>Scans all model.Functions for DAXLIB_PackageId annotations. Groups by package with version and function count. Functions without annotations show under (no package).
Options Reference
| Flag | Short | Used by | Description |
|---|---|---|---|
--port | -p | add, update, remove, installed | PBI Desktop Analysis Services port |
--version | -v | info, functions, download, add, update | Package version (default: latest stable) |
--fn | -f | download, add, remove | Comma-separated function names; supports suffix match |
--output | -o | download | Output directory for TMDL file |
--json | (reserved) | JSON output |
Prerequisites
ghCLI authenticated (for registry browsing; falls back tocurl)- .NET 8 SDK (for model operations)
- Power BI Desktop open with a model loaded (for add/update/remove/installed)
Priority Packages
DaxLib.SVG (v1.0.1)
Composable SVG generation functions for Power BI tables, matrices, and cards. 58 functions across these categories:
| Category | Functions | Purpose |
|---|---|---|
DaxLib.SVG.SVG | 1 | Root SVG container with sort value support |
DaxLib.SVG.Element.* | ~10 | Primitives: Rect, Circle, Line, Text, Polygon, Polyline, Ellipse, Path, Group |
DaxLib.SVG.Attr.* | 3 | Attribute builders: Shapes (fill/opacity), Stroke, Text |
DaxLib.SVG.Def.* | ~5 | Defs: LinearGradient, RadialGradient, GradientStop, ClipPath |
DaxLib.SVG.Scale.* | ~4 | Normalize, NiceNum, NiceRange for axis scaling |
DaxLib.SVG.Axes.* | ~4 | Axis layout, rendering, baseline, tick points |
DaxLib.SVG.Color.* | ~5 | Theme colors, PerformanceTheme, Hex/RGB/Int conversions |
DaxLib.SVG.Viz.* | ~10 | Compound visuals: Bar, Line, Area, ProgressBar, Pill, Boxplot, Jitter, Heatmap, Violin |
DaxLib.SVG.Transforms | 1 | SVG transform attribute builder |
Functions output data:image/svg+xml;utf8,... URIs. Set the column's data category to "Image URL" in Power BI to render inline.
Example: inline bar chart measure
Bar Chart =
VAR _Value = [Total Revenue]
VAR _Max = MAXX(ALLSELECTED('Product'[Category]), [Total Revenue])
VAR _Width = 200
VAR _BarWidth = ROUND(_Value / _Max * _Width, 0)
VAR _Bar =
[DaxLib.SVG.Element.Rect](
0, 2, _BarWidth, 16, BLANK(),
[DaxLib.SVG.Attr.Shapes]("#4472C4", BLANK(), BLANK(), BLANK(), BLANK(), BLANK(), BLANK()),
BLANK(), BLANK()
)
VAR _Label =
[DaxLib.SVG.Element.Text](
_BarWidth + 4, 14, FORMAT(_Value, "$#,0"), BLANK(),
[DaxLib.SVG.Attr.Txt]("Segoe UI", 11, BLANK(), BLANK(), "start", BLANK(), BLANK(), BLANK(), BLANK()),
BLANK(), BLANK()
)
RETURN
[DaxLib.SVG.SVG]("300", "20", BLANK(), _Bar & _Label, _Value)Docs: evaluationcontext.github.io/daxlib.svg Dev repo: github.com/daxlib/dev-daxlib-svg
PowerofBI.IBCS (v0.11.0)
IBCS (International Business Communication Standards)-guided SVG visualizations. 12 functions for standardized business charts:
| Function | Purpose |
|---|---|
BarChart.AbsoluteValues | Horizontal bar comparing AC vs PY/BU |
BarChart.AbsoluteVariance | Absolute variance bars (AC - PY/BU) |
BarChart.RelativeVariance | Relative variance bars (% change) |
ColumnChart.WithWaterfall | Vertical columns with waterfall bridge |
ColumnChart.SmallMultiple | Small multiple column charts |
MultiplierAnalysis | Multiplicative decomposition chart |
Helpers.Title | IBCS-styled chart title |
| (+ 5 more) | Additional chart types and helpers |
These functions use EXPR-type parameters; they accept measure references and dimension columns directly, computing ALLSELECTED scoping internally.
Example: IBCS bar chart measure
IBCS Bar =
[PowerofBI.IBCS.BarChart.AbsoluteValues](
'Product'[Category], -- dimension column (ANYREF EXPR)
[AC], -- actuals measure (SCALAR EXPR)
BLANK(), -- forecast (SCALAR EXPR, optional)
[PY], -- base value (SCALAR EXPR)
"grey", -- base styling: "grey" for PY, "outlined" for BU
FORMAT([AC], "#,0"), -- data label
350, -- image width (match Format pane)
35, -- image height (match Format pane)
FALSE() -- sync with absolute variance chart
)Docs: powerofbi.org/ibcs Dev repo: github.com/avatorl/dax-udf-svg-ibcs
Using Installed Functions in Measures
Once installed, UDFs are callable from any measure, calculated column, or calculation item in the model:
-- Call with scalar parameters
[DaxLib.SVG.Element.Rect](x, y, width, height, rx, shapeAttrs, strokeAttrs, extras)
-- Call with EXPR parameters (pass measure/column references directly)
[PowerofBI.IBCS.BarChart.AbsoluteValues]('Dim'[Col], [Measure1], ...)For SVG output: create a measure that returns the SVG URI string, then set the column or measure's data category to "Image URL" so Power BI renders it inline. SVG visuals work in Table, Matrix, Card, and Multi-row Card visuals.
Other Notable Packages
Browse all packages at daxlib.org. Some highlights:
| Package | Description |
|---|---|
DaxLib.Convert | Type conversion utilities |
DaxLib.FormatString | Dynamic format string builders |
DaxLib.Filtering | Reusable filter patterns |
DaxLib.Records | Record/row manipulation helpers |
DaxLib.Sample | Example/template package |
Desktop Bridge: driving the report canvas over the raw named pipe
Power BI Desktop exposes a second local API beside the Analysis Services engine: a per-process JSON-RPC server on a Windows named pipe that controls the report canvas (reload + snapshot). When the `pbir` CLI is installed, use `pbir desktop list/refresh/screenshot` instead of anything below; never drive the pipe from PowerShell when `pbir` is available. This raw-pipe path exists for machines without pbir: PowerShell straight to the pipe, the same way this skill rawdogs TOM/ADOMD. (The powerbi-desktop npm CLI wraps these same methods; the pbir-format and pbir-cli skills cover that wrapper path.)
Use it together with the model API: change the model with TOM, then reload and snapshot the report to confirm the visuals reflect the change.
The endpoint
- Pipe:
\\.\pipe\pbi-desktop-bridge-<PID>, one per Desktop process. Discover it by
enumerating the pipe directory for the prefix; <PID> is the PBIDesktop.exe id:
[System.IO.Directory]::GetFiles("\\.\pipe\") |
Where-Object { $_ -match 'pbi-desktop-bridge-(\d+)$' }The pipe exists only when the bridge preview setting is enabled: in Power BI Desktop, File > Options and settings > Options > Preview features, turn on the developer-mode / report-bridge preview feature and restart Desktop. If no pipe is found, that setting is off or Desktop is closed.
- Protocol: JSON-RPC 2.0 with LSP-style
Content-Lengthframing (vscode-jsonrpc over
the pipe stream). No initialize handshake; connect and call.
Methods (params and returns, as the bridge defines them)
bridge.manifest: {} -> capability manifest (call first to confirm availability)
application.state.get/v1: {} -> { currentFilePath, hasUnsavedChanges }
file.reload/v1: { reloadModelDefinition: false } -> { success } # reloads on-disk PBIR into the live canvas
report.snapshot.capture/v1: { pageId, scale } -> { payload, encoding, pageId, pageDisplayName, mimeType }pageIdis the PBIR section id (e.g.ReportSection1a2b3c), not the display name.scaleis 1 to 3 (2 is a good default for readable review).report.snapshot.capture/v1returns the PNG as a base64 string inpayload
(encoding is base64, mimeType is image/png).
file.reload/v1withreloadModelDefinition: falsereloads the report definition
only; this is the canvas refresh after a PBIR edit.
Raw PowerShell client
# 1. connect to the bridge pipe for the target Desktop process
$procId = (Get-Process PBIDesktop -ErrorAction Stop | Select-Object -First 1).Id
$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", "pbi-desktop-bridge-$procId",
[System.IO.Pipes.PipeDirection]::InOut)
$pipe.Connect(5000)
# 2. JSON-RPC over the pipe with LSP Content-Length framing
function Read-Frame($pipe) {
$win = ""; $one = New-Object byte[] 1; $hdr = New-Object Text.StringBuilder
while ($true) {
if ($pipe.Read($one, 0, 1) -le 0) { throw "pipe closed" }
$c = [char]$one[0]; [void]$hdr.Append($c)
$win = ($win + $c); if ($win.Length -gt 4) { $win = $win.Substring(1) }
if ($win -eq "`r`n`r`n") { break }
}
$len = [int]([regex]::Match($hdr.ToString(), 'Content-Length:\s*(\d+)').Groups[1].Value)
$buf = New-Object byte[] $len; $off = 0
while ($off -lt $len) { $n = $pipe.Read($buf, $off, $len - $off); if ($n -le 0) { throw "eof" }; $off += $n }
[Text.Encoding]::UTF8.GetString($buf) | ConvertFrom-Json
}
function Invoke-Bridge($pipe, [int]$id, [string]$method, $params) {
$json = @{ jsonrpc = "2.0"; id = $id; method = $method; params = $params } | ConvertTo-Json -Compress -Depth 10
$body = [Text.Encoding]::UTF8.GetBytes($json)
$header = [Text.Encoding]::ASCII.GetBytes("Content-Length: $($body.Length)`r`n`r`n")
$pipe.Write($header, 0, $header.Length); $pipe.Write($body, 0, $body.Length); $pipe.Flush()
Read-Frame $pipe
}
# 3. calls
Invoke-Bridge $pipe 1 "bridge.manifest" @{} | Out-Null
$state = Invoke-Bridge $pipe 2 "application.state.get/v1" @{} # confirm $state.currentFilePath matches your PBIP
Invoke-Bridge $pipe 3 "file.reload/v1" @{ reloadModelDefinition = $false } # refresh the canvas after a PBIR edit
$shot = Invoke-Bridge $pipe 4 "report.snapshot.capture/v1" @{ pageId = "ReportSection1a2b3c"; scale = 2 }
[IO.File]::WriteAllBytes("page.png", [Convert]::FromBase64String($shot.payload))
$pipe.Dispose()The reader reads header bytes one at a time up to \r\n\r\n, then exactly Content-Length body bytes; do not wrap the pipe in a buffering StreamReader, it will swallow the next frame's bytes.
Model-and-report loop, rawdogged
# model edit via TOM (this skill's main body), applied live
$model.Tables["Sales"].Measures["Revenue"].FormatString = "\$#,0"
$model.SaveChanges()
# then confirm the report reflects it, no reopen
Invoke-Bridge $pipe 5 "file.reload/v1" @{ reloadModelDefinition = $false }
$shot = Invoke-Bridge $pipe 6 "report.snapshot.capture/v1" @{ pageId = "ReportSection1a2b3c"; scale = 2 }
[IO.File]::WriteAllBytes("sales.png", [Convert]::FromBase64String($shot.payload))On-disk PBIR (report) edits are picked up by file.reload/v1. On-disk TMDL (model) edits are not; prefer live TOM SaveChanges() for model changes.
Locating the open PBIP from the bridge
You do not need the file path in advance. Enumerate the pipe directory to auto-discover the running Desktop PID, connect, and call application.state.get/v1; its currentFilePath is the open .pbip/.pbix on disk. From it you have the project folder and its .Report (PBIR) and .SemanticModel siblings, ready to drive with pbir / the pbir-format skill. This is more reliable than the recent-file-history method (section 10), which reads history rather than the live instance.
$procId = [System.IO.Directory]::GetFiles("\\.\pipe\") |
ForEach-Object { if ($_ -match 'pbi-desktop-bridge-(\d+)$') { $matches[1] } } |
Select-Object -First 1
# connect to pbi-desktop-bridge-$procId (above), then:
$state = Invoke-Bridge $pipe 1 "application.state.get/v1" @{}
$pbip = $state.currentFilePath # e.g. C:\Reports\Sales\Sales.pbip
$reportDir = Join-Path (Split-Path $pbip) ((Split-Path $pbip -LeafBase) + ".Report")Higher-level wrapper: the pbir CLI
If the raw pipe client misbehaves (framing, encoding, or a build that changed a param shape), use the pbir desktop commands (reports plugin pbir-cli skill); they speak the same methods over the same pipe (the same preview setting must be enabled):
pbir desktop list # list instances; pick the PID (`status` is an alias)
pbir desktop reload --pid <pid> # wraps file.reload/v1
pbir desktop screenshot "<report>/<page>.Page" --pid <pid> -o page.png # wraps report.snapshot.capture/v1Same endpoint, higher level. The pbir-cli skill documents this path in full.
Notes
- macOS: run inside the Parallels VM, the same path this skill uses for PowerShell;
see parallels-macos.md.
- To CHANGE visuals, pages, or formatting, route to the
pbir-cliskill; the bridge
only reloads and snapshots, it never edits the report.
- This is the named-pipe API distinct from the Analysis Services XMLA port this skill
connects to for the model; the two are independent.
bin/
obj/
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AnalysisServices" Version="19.113.2" />
</ItemGroup>
</Project>