← Files Power BI DesktopARCHIVED FILE

scripts/Compare-PowerBILiveRepoModel.ps1

7.31 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

param(
    [string]$Path = ".",
    [string]$Server,
    [int]$Port,
    [switch]$RequireSingle,
    [string]$OutputPath,
    [switch]$Json
)

$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path

function Normalize-Expression {
    param([string]$Expression)
    if ($null -eq $Expression) { return '' }
    return (($Expression -replace '\s+', ' ').Trim())
}

function Get-MeasureKey {
    param($Measure)
    $table = if ($Measure.table) { [string]$Measure.table } else { '' }
    $name = if ($Measure.name) { [string]$Measure.name } else { '' }
    return ("$table|$name").ToLowerInvariant()
}

function Get-RepoTables {
    param([string]$RootPath, $Catalog)

    $names = New-Object System.Collections.Generic.HashSet[string]
    foreach ($metric in @($Catalog.metrics)) {
        if ($metric.table) { [void]$names.Add([string]$metric.table) }
    }

    $files = Get-ChildItem -LiteralPath $RootPath -Recurse -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Extension.ToLowerInvariant() -in @('.tmdl', '.pq') }
    foreach ($file in $files) {
        $text = Get-Content -Raw -LiteralPath $file.FullName
        if ($file.Extension.ToLowerInvariant() -eq '.tmdl') {
            foreach ($match in [regex]::Matches($text, '(?m)^\s*table\s+(''(?<quoted>[^'']+)''|(?<plain>[^\r\n]+?))\s*$')) {
                $name = if ($match.Groups['quoted'].Success) { $match.Groups['quoted'].Value } else { $match.Groups['plain'].Value.Trim() }
                if ($name) { [void]$names.Add($name) }
            }
        }
        elseif ($file.Extension.ToLowerInvariant() -eq '.pq') {
            [void]$names.Add([System.IO.Path]::GetFileNameWithoutExtension($file.Name))
        }
    }

    @($names | ForEach-Object { [pscustomobject]@{ name = $_; source = 'repo' } })
}

$repo = & (Join-Path $scriptRoot 'New-PowerBIMetricCatalog.ps1') -Path $Path -Json | ConvertFrom-Json
$repoRoot = (Resolve-Path -LiteralPath $Path).Path
$repoTables = @(Get-RepoTables -RootPath $repoRoot -Catalog $repo)
$live = $null
$liveSummary = $null
$liveStatus = 'LiveUnavailable'
$liveDetail = ''
$resolvedServer = $Server
try {
    if (-not $resolvedServer) {
        $targetArgs = @{}
        if ($Port) { $targetArgs.Port = $Port }
        if ($RequireSingle) { $targetArgs.RequireSingle = $true }
        $target = & (Join-Path $scriptRoot 'Resolve-PowerBILiveTarget.ps1') @targetArgs -Json | ConvertFrom-Json
        if ($target.status -ne 'TargetResolved' -or -not $target.target.connectionString) {
            throw $target.reason
        }
        $resolvedServer = $target.target.connectionString
    }
    $live = & (Join-Path $scriptRoot 'New-PowerBILiveMetricCatalog.ps1') -Server $resolvedServer -Json | ConvertFrom-Json
    $liveSummary = & (Join-Path $scriptRoot 'Get-PowerBILiveModelSummary.ps1') -Server $resolvedServer -Json | ConvertFrom-Json
}
catch {
    $liveDetail = $_.Exception.Message
}

$differences = New-Object System.Collections.Generic.List[object]
if ($live) {
    $repoByName = @{}
    foreach ($m in @($repo.metrics)) { $repoByName[(Get-MeasureKey -Measure $m)] = $m }
    $liveByName = @{}
    foreach ($m in @($live.metrics)) { $liveByName[(Get-MeasureKey -Measure $m)] = $m }
    foreach ($name in $repoByName.Keys) {
        $repoMeasure = $repoByName[$name]
        if (-not $liveByName.ContainsKey($name)) { $differences.Add([pscustomobject]@{ type = 'RepoOnlyMeasure'; objectType = 'Measure'; table = $repoMeasure.table; measure = $repoMeasure.name; risk = 'Medium'; detail = 'Measure exists in repo but not live Desktop model.' }) }
        elseif ((Normalize-Expression $repoMeasure.expression) -ne (Normalize-Expression $liveByName[$name].expression)) { $differences.Add([pscustomobject]@{ type = 'ExpressionDrift'; objectType = 'Measure'; table = $repoMeasure.table; measure = $repoMeasure.name; risk = 'High'; detail = 'Measure expression differs between repo and live Desktop model.' }) }
    }
    foreach ($name in $liveByName.Keys) {
        $liveMeasure = $liveByName[$name]
        if (-not $repoByName.ContainsKey($name)) { $differences.Add([pscustomobject]@{ type = 'LiveOnlyMeasure'; objectType = 'Measure'; table = $liveMeasure.table; measure = $liveMeasure.name; risk = 'High'; detail = 'Measure exists live but is not represented in repo/source control.' }) }
    }

    if ($liveSummary) {
        $repoTableByName = @{}
        foreach ($table in @($repoTables)) { if ($table.name) { $repoTableByName[[string]$table.name] = $table } }
        $liveTableByName = @{}
        foreach ($table in @($liveSummary.tables)) { if ($table.Name) { $liveTableByName[[string]$table.Name] = $table } }
        foreach ($name in $repoTableByName.Keys) {
            if (-not $liveTableByName.ContainsKey($name)) { $differences.Add([pscustomobject]@{ type = 'RepoOnlyTable'; objectType = 'Table'; table = $name; measure = $null; risk = 'Medium'; detail = 'Table appears in repo artifacts but not live Desktop model.' }) }
        }
        foreach ($name in $liveTableByName.Keys) {
            if (-not $repoTableByName.ContainsKey($name)) { $differences.Add([pscustomobject]@{ type = 'LiveOnlyTable'; objectType = 'Table'; table = $name; measure = $null; risk = 'Medium'; detail = 'Table exists live but is not represented in repo artifacts detected by the plugin.' }) }
        }
    }

    $liveStatus = if ($differences.Count -gt 0) { 'DriftDetected' } else { 'NoDrift' }
}

$result = [pscustomobject]@{
    schema = 'codex.powerbi.liveRepoReconciliation.v1'
    generated = (Get-Date).ToString('s')
    repoPath = $repoRoot
    server = $resolvedServer
    liveStatus = $liveStatus
    liveDetail = $liveDetail
    repoMeasureCount = $repo.metricCount
    repoTableCount = $repoTables.Count
    liveMeasureCount = if ($live) { $live.metricCount } else { $null }
    liveTableCount = if ($liveSummary) { $liveSummary.tableCount } else { $null }
    differenceCount = $differences.Count
    differences = @($differences.ToArray())
    statusMeaning = [pscustomobject]@{
        LiveUnavailable = 'No live comparison was performed because Desktop endpoint or ADOMD access was unavailable.'
        NoDrift = 'Live endpoint was available and no supported measure/table drift was detected.'
        DriftDetected = 'Live endpoint was available and supported measure/table drift was detected.'
    }
}

if ($Json) {
    $text = $result | ConvertTo-Json -Depth 8
    if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding UTF8 }
    $text
    return
}

$md = @(
    '# Power BI Live vs Repo Reconciliation',
    '',
    "Live status: $($result.liveStatus)",
    "Live detail: $($result.liveDetail)",
    "Repo measures: $($result.repoMeasureCount)",
    "Live measures: $($result.liveMeasureCount)",
    "Repo tables: $($result.repoTableCount)",
    "Live tables: $($result.liveTableCount)",
    "Differences: $($result.differenceCount)",
    ''
) + @($result.differences | ForEach-Object {
    $objectName = if ($_.objectType -eq 'Measure') { "$($_.table)[$($_.measure)]" } else { $_.table }
    "- [$($_.risk)] $($_.type): `$objectName` - $($_.detail)"
})
$content = ($md -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $content -Encoding UTF8 }
$content

SHA-256: cbae49b19a5bef4561573451f14c508afe13d535ea4a69ed6f50bbf83e871d27