← Files Power BI DesktopARCHIVED FILE
scripts/Compare-PowerBIMeasureBehavior.ps1
9.41 KB · Oct 2, 2026 · 00:32 UTC
param(
[string]$BeforePath,
[string]$AfterPath,
[string]$Path = ".",
[string]$ExpectationsPath,
[string]$BaselineResultsPath,
[string]$CurrentResultsPath,
[string]$BaselineServer,
[string]$CurrentServer,
[string]$OutputPath,
[switch]$Json
)
$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
function ConvertTo-StableJson {
param([object]$Value)
if ($null -eq $Value) { return '{}' }
return ($Value | ConvertTo-Json -Depth 12 -Compress)
}
function ConvertTo-ComparableNumber {
param([object]$Value)
if ($null -eq $Value) { return $null }
if ($Value -is [byte] -or $Value -is [int16] -or $Value -is [int32] -or $Value -is [int64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) {
return [double]$Value
}
$parsed = 0.0
if ([double]::TryParse([string]$Value, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) {
return $parsed
}
return $null
}
function Compare-Values {
param([object]$Baseline, [object]$Current, [double]$Tolerance)
$baselineNumber = ConvertTo-ComparableNumber $Baseline
$currentNumber = ConvertTo-ComparableNumber $Current
if ($null -ne $baselineNumber -and $null -ne $currentNumber) {
$delta = $currentNumber - $baselineNumber
return [pscustomobject]@{
passed = ([Math]::Abs($delta) -le $Tolerance)
delta = $delta
}
}
return [pscustomobject]@{
passed = ([string]$Baseline -eq [string]$Current)
delta = $null
}
}
function Read-MeasureResults {
param([string]$FilePath)
$doc = Get-Content -Raw -LiteralPath $FilePath | ConvertFrom-Json
if ($doc.PSObject.Properties.Name -contains 'checks') { return @($doc.checks) }
if ($doc.PSObject.Properties.Name -contains 'tests') { return @($doc.tests) }
if ($doc.PSObject.Properties.Name -contains 'results') { return @($doc.results) }
throw "Unsupported measure result file: $FilePath"
}
function New-ResultKey {
param([object]$Item)
$measure = if ($Item.PSObject.Properties.Name -contains 'measure') { $Item.measure } elseif ($Item.PSObject.Properties.Name -contains 'name') { $Item.name } else { '' }
$filters = if ($Item.PSObject.Properties.Name -contains 'filters') { $Item.filters } elseif ($Item.PSObject.Properties.Name -contains 'filterContext') { $Item.filterContext } else { $null }
return ('{0}|{1}' -f $measure, (ConvertTo-StableJson $filters))
}
function Get-ItemValue {
param([object]$Item)
if ($Item.PSObject.Properties.Name -contains 'actual') { return $Item.actual }
if ($Item.PSObject.Properties.Name -contains 'value') { return $Item.value }
return $null
}
function Get-ItemTolerance {
param([object]$Item)
if ($Item.PSObject.Properties.Name -contains 'tolerance' -and $null -ne $Item.tolerance) { return [double]$Item.tolerance }
return 0.0
}
$comparisons = @()
$mode = $null
if ($BaselineResultsPath -and $CurrentResultsPath) {
$mode = 'ResultFiles'
$baseline = Read-MeasureResults -FilePath $BaselineResultsPath
$current = Read-MeasureResults -FilePath $CurrentResultsPath
$baselineByKey = @{}
foreach ($item in $baseline) { $baselineByKey[(New-ResultKey $item)] = $item }
$currentByKey = @{}
foreach ($item in $current) { $currentByKey[(New-ResultKey $item)] = $item }
$keys = @($baselineByKey.Keys + $currentByKey.Keys | Sort-Object -Unique)
$comparisons = foreach ($key in $keys) {
$base = $baselineByKey[$key]
$curr = $currentByKey[$key]
$measure = if ($curr) { $curr.measure } elseif ($base.PSObject.Properties.Name -contains 'measure') { $base.measure } else { $base.name }
$filters = if ($curr -and $curr.PSObject.Properties.Name -contains 'filters') { $curr.filters } elseif ($base -and $base.PSObject.Properties.Name -contains 'filters') { $base.filters } else { $null }
if (-not $base -or -not $curr) {
[pscustomobject]@{
measure = $measure
filterContext = $filters
status = 'Failed'
baseline = if ($base) { Get-ItemValue $base } else { $null }
current = if ($curr) { Get-ItemValue $curr } else { $null }
delta = $null
tolerance = if ($curr) { Get-ItemTolerance $curr } else { 0 }
detail = 'Measure/context exists only on one side of the comparison.'
}
continue
}
$tolerance = Get-ItemTolerance $curr
$comparison = Compare-Values -Baseline (Get-ItemValue $base) -Current (Get-ItemValue $curr) -Tolerance $tolerance
[pscustomobject]@{
measure = $measure
filterContext = $filters
status = if ($comparison.passed) { 'Passed' } else { 'Failed' }
baseline = Get-ItemValue $base
current = Get-ItemValue $curr
delta = $comparison.delta
tolerance = $tolerance
detail = if ($comparison.passed) { 'Current value matches baseline within tolerance.' } else { 'Current value differs from baseline beyond tolerance.' }
}
}
}
elseif ($ExpectationsPath -and $BaselineServer -and $CurrentServer) {
$mode = 'LiveDax'
$baselineFile = [System.IO.Path]::GetTempFileName()
$currentFile = [System.IO.Path]::GetTempFileName()
try {
& (Join-Path $scriptRoot 'Test-PowerBIMeasureExpectations.ps1') -Path $Path -ExpectationsPath $ExpectationsPath -Server $BaselineServer -OutputPath $baselineFile -Json | Out-Null
& (Join-Path $scriptRoot 'Test-PowerBIMeasureExpectations.ps1') -Path $Path -ExpectationsPath $ExpectationsPath -Server $CurrentServer -OutputPath $currentFile -Json | Out-Null
$reinvoke = & $PSCommandPath -BaselineResultsPath $baselineFile -CurrentResultsPath $currentFile -Json | ConvertFrom-Json
$comparisons = @($reinvoke.comparisons)
}
finally {
if (Test-Path -LiteralPath $baselineFile) { Remove-Item -LiteralPath $baselineFile -Force }
if (Test-Path -LiteralPath $currentFile) { Remove-Item -LiteralPath $currentFile -Force }
}
}
elseif ($ExpectationsPath) {
$mode = 'Plan'
$generated = & (Join-Path $scriptRoot 'Test-PowerBIMeasureExpectations.ps1') -Path $Path -ExpectationsPath $ExpectationsPath -GenerateOnly -Json | ConvertFrom-Json
$comparisons = @($generated.checks | ForEach-Object {
[pscustomobject]@{
measure = $_.measure
filterContext = $_.filters
status = 'NotAvailable'
baseline = $null
current = $null
delta = $null
tolerance = $_.tolerance
daxQuery = $_.daxQuery
detail = 'Baseline/current live servers or result files were not supplied; deterministic comparison plan only.'
}
})
}
elseif ($BeforePath -and $AfterPath) {
$mode = 'ModelDiffPlan'
$diff = & (Join-Path $scriptRoot 'Compare-PowerBISemanticModel.ps1') -BeforePath $BeforePath -AfterPath $AfterPath -Json | ConvertFrom-Json
$tests = & (Join-Path $scriptRoot 'New-PowerBIMeasureTestPlan.ps1') -Path $AfterPath -Json | ConvertFrom-Json
$comparisons = foreach ($change in @($diff.changes)) {
[pscustomobject]@{
id = $change.id
changeType = $change.type
behaviorRisk = $change.risk
status = 'NotAvailable'
validationQueries = @($tests.tests | Where-Object { $_.measure -eq (($change.id -split '\.')[-1] -replace '-', ' ') } | Select-Object -ExpandProperty daxQuery)
tolerance = 'Business owner defines acceptable variance.'
detail = 'Model metadata changed; supply result files or live servers for value comparison.'
rollbackNote = 'Revert changed measure expression from previous PBIP/TMDL version if validation fails.'
}
}
}
else {
$mode = 'Plan'
$comparisons = @()
}
$failed = @($comparisons | Where-Object { $_.status -eq 'Failed' })
$notAvailable = @($comparisons | Where-Object { $_.status -eq 'NotAvailable' })
$result = [pscustomobject]@{
schema = 'codex.powerbi.measureBehaviorComparison.v2'
mode = $mode
generated = (Get-Date).ToString('s')
status = if ($failed.Count -gt 0) { 'Failed' } elseif ($notAvailable.Count -gt 0) { 'NotAvailable' } else { 'Passed' }
comparisonCount = @($comparisons).Count
failedCount = $failed.Count
notAvailableCount = $notAvailable.Count
comparisons = @($comparisons)
}
if ($Json) {
$text = $result | ConvertTo-Json -Depth 12
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding UTF8 }
$text
return
}
$md = @(
'# Power BI Measure Behavior Diff',
'',
"Mode: $($result.mode)",
"Status: $($result.status)",
"Comparisons: $($result.comparisonCount)",
"Failed: $($result.failedCount)",
"Not available: $($result.notAvailableCount)",
''
) + @($comparisons | ForEach-Object {
$name = if ($_.PSObject.Properties.Name -contains 'measure') { $_.measure } else { $_.id }
"- [$($_.status)] $name - $($_.detail)"
})
$content = ($md -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $content -Encoding UTF8 }
$content
SHA-256: dd52c5c5ee4e8abb5ec9f65a6d4bb338adc977216d37ce1bed9f536804ff2342