← Files Power BI DesktopARCHIVED FILE

scripts/New-PowerBIMetricCatalog.ps1

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

↓ Download file

param(
    [string]$Path = ".",
    [string]$OutputPath,
    [switch]$Json
)

$ErrorActionPreference = 'Stop'

$root = (Resolve-Path -LiteralPath $Path).Path

function Get-RelativePath {
    param([string]$BasePath, [string]$TargetPath)
    if ([System.IO.Path].GetMethod('GetRelativePath', [type[]]@([string], [string]))) {
        return [System.IO.Path]::GetRelativePath($BasePath, $TargetPath)
    }
    $baseUri = [Uri]((Join-Path $BasePath '.') + [System.IO.Path]::DirectorySeparatorChar)
    $targetUri = [Uri]$TargetPath
    [Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString()).Replace('/', [System.IO.Path]::DirectorySeparatorChar)
}

function Get-DaxMeasureBlocks {
    param([string]$Text, [string]$RelativePath)

    $pattern = '(?ms)^\s*(?:MEASURE\s+)?(?:(?<table>''[^'']+''|\w+)\s*)?\[(?<name>[^\]\r\n=]+)\]\s*=\s*(?<expr>.*?)(?=^\s*(?:MEASURE\s+)?(?:(?:''[^'']+''|\w+)\s*)?\[[^\]\r\n=]+\]\s*=|\z)'
    foreach ($match in [regex]::Matches($Text, $pattern)) {
        $name = ($match.Groups['name'].Value).Trim()
        $expr = ($match.Groups['expr'].Value).Trim()
        if ($name -and $expr) {
            [pscustomobject]@{
                Name = $name
                Table = ($match.Groups['table'].Value).Trim("' ")
                Expression = $expr
                Source = $RelativePath
            }
        }
    }
}

function Get-MetricTags {
    param([string]$Name, [string]$Expression)

    $tags = New-Object System.Collections.Generic.List[string]
    $probe = "$Name $Expression"
    if ($probe -match '(?i)sales|revenue|amount|margin|profit') { $tags.Add('finance') }
    if ($probe -match '(?i)yoy|prior year|previous year|sameperiodlastyear') { $tags.Add('time-intelligence') }
    if ($probe -match '(?i)customer|account|active') { $tags.Add('customer') }
    if ($probe -match '(?i)divide\s*\(') { $tags.Add('ratio') }
    if ($probe -match '(?i)count|distinctcount') { $tags.Add('counting') }
    if ($tags.Count -eq 0) { $tags.Add('uncategorized') }
    $tags
}

function Get-MetricRisks {
    param([string]$Expression)

    $risks = New-Object System.Collections.Generic.List[string]
    if ($Expression -match '(?i)\bFILTER\s*\(\s*ALL\s*\(') { $risks.Add('performance: FILTER over ALL') }
    if ($Expression -match '(?i)\bTODAY\s*\(|\bNOW\s*\(') { $risks.Add('determinism: volatile date/time') }
    if ($Expression -match '(?i)\bEARLIER\s*\(') { $risks.Add('maintainability: EARLIER') }
    if ($Expression -match '(?i)\bUSERELATIONSHIP\s*\(') { $risks.Add('semantics: inactive relationship') }
    if (($Expression -split "`r?`n").Count -gt 20) { $risks.Add('maintainability: long expression') }
    $risks
}

$measureFiles = Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue |
    Where-Object { $_.Extension.ToLowerInvariant() -in @('.dax', '.tmdl') } |
    Sort-Object FullName

$metrics = foreach ($file in $measureFiles) {
    $relative = Get-RelativePath -BasePath $root -TargetPath $file.FullName
    $text = Get-Content -Raw -LiteralPath $file.FullName
    Get-DaxMeasureBlocks -Text $text -RelativePath $relative | ForEach-Object {
        $risks = @(Get-MetricRisks -Expression $_.Expression)
        [pscustomobject]@{
            id = (($_.Table + '.' + $_.Name).Trim('.')).ToLowerInvariant().Replace(' ', '-').Replace('%', 'pct')
            name = $_.Name
            table = $_.Table
            source = $_.Source
            tags = @(Get-MetricTags -Name $_.Name -Expression $_.Expression)
            riskLevel = if ($risks.Count -gt 0) { 'review' } else { 'normal' }
            risks = $risks
            owner = '[TODO: metric owner]'
            businessDefinition = '[TODO: business definition]'
            validationQuestion = ('Does `{0}` reconcile to the accepted business source for the selected filter context?' -f $_.Name)
            expression = $_.Expression
        }
    }
}

$catalog = [pscustomobject]@{
    schema = 'codex.powerbi.metricCatalog.v1'
    root = $root
    generated = (Get-Date).ToString('s')
    metricCount = @($metrics).Count
    metrics = @($metrics)
}

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

$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# Power BI Metric Catalog')
$lines.Add('')
$lines.Add(('Schema: `{0}`' -f $catalog.schema))
$lines.Add(('Root: `{0}`' -f $catalog.root))
$lines.Add(('Generated: {0}' -f $catalog.generated))
$lines.Add(('Metrics: {0}' -f $catalog.metricCount))
$lines.Add('')
foreach ($metric in $catalog.metrics) {
    $title = if ($metric.table) { "$($metric.table)[$($metric.name)]" } else { $metric.name }
    $lines.Add(('## {0}' -f $title))
    $lines.Add('')
    $lines.Add(('- ID: `{0}`' -f $metric.id))
    $lines.Add(('- Source: `{0}`' -f $metric.source))
    $lines.Add(('- Tags: {0}' -f ($metric.tags -join ', ')))
    $lines.Add(('- Risk level: {0}' -f $metric.riskLevel))
    if ($metric.risks.Count -gt 0) {
        $lines.Add(('- Risks: {0}' -f ($metric.risks -join '; ')))
    }
    $lines.Add(('- Owner: {0}' -f $metric.owner))
    $lines.Add(('- Business definition: {0}' -f $metric.businessDefinition))
    $lines.Add(('- Validation question: {0}' -f $metric.validationQuestion))
    $lines.Add('')
    $lines.Add('```DAX')
    $lines.Add($metric.expression)
    $lines.Add('```')
    $lines.Add('')
}

$markdown = ($lines -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) {
    Set-Content -LiteralPath $OutputPath -Value $markdown -Encoding UTF8
}
$markdown

SHA-256: 4ae0ae0e97240b88076efd63ab852393f30baaa194c598a84fe0e3ef260f76b7