Invoke-DQVTesting.psm1
# Assumes commercial environment $script:pbiAPIURL = "https://api.powerbi.com" $script:xMLAPrefix = "powerbi://api.powerbi.com/v1.0/myorg/" $script:messages = @() #Install Az.Accounts if Needed if (!(Get-Module -ListAvailable -Name "Az.Accounts")) { #Install Az.Accounts Module Install-Module -Name Az.Accounts -Scope CurrentUser -AllowClobber -Force } # Load the type from the Microsoft.AnalysisServices.AdomdClient nuget package $currentPath = (Split-Path $MyInvocation.MyCommand.Definition -Parent) $nugets = @( @{ name = "Microsoft.AnalysisServices.AdomdClient.NetCore.retail.amd64" ; version = "19.77.0" ; path = @("lib\netcoreapp3.0\Microsoft.AnalysisServices.AdomdClient.dll", "lib\netcoreapp3.0\Microsoft.AnalysisServices.Runtime.Core.dll", "lib/netcoreapp3.0/Microsoft.AnalysisServices.Runtime.Windows.dll") } ) foreach ($nuget in $nugets) { Write-Output "Downloading and installing Nuget: $($nuget.name)" if (!(Test-Path "$currentPath\.nuget\$($nuget.name)*" -PathType Container)) { Install-Package -Name $nuget.name -ProviderName NuGet -Destination "$currentPath\.nuget" -RequiredVersion $nuget.Version -SkipDependencies -AllowPrereleaseVersions -Scope CurrentUser -Force } foreach ($nugetPath in $nuget.path) { Write-Output "Loading assemblies of: '$($nuget.name)'" $path = Resolve-Path (Join-Path "$currentPath\.nuget\$($nuget.name).$($nuget.Version)" $nugetPath) Add-Type -Path $path -Verbose | Out-Null } } # Create a new directory in the current location if((Test-Path -path ".\.nuget\custom_modules") -eq $false){ New-Item -Name ".nuget\custom_modules" -Type Directory } # For each url download and install in module folder @("https://raw.githubusercontent.com/microsoft/Analysis-Services/master/pbidevmode/fabricps-pbip/FabricPS-PBIP.psm1", "https://raw.githubusercontent.com/microsoft/Analysis-Services/master/pbidevmode/fabricps-pbip/FabricPS-PBIP.psd1") | ForEach-Object { Invoke-WebRequest -Uri $_ -OutFile ".\.nuget\custom_modules\$(Split-Path $_ -Leaf)" } # Import FabricPS-PBIP Import-Module ".\.nuget\custom_modules\FabricPS-PBIP" -Force function Write-ToLog { param ( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [ValidateSet('Debug','Warning','Error','Failure')] [string]$LogType = 'Debug', [Parameter(Mandatory = $false)] [ValidateSet('ADO','Host','Table')] [string]$LogOutput = 'ADO', [Parameter(Mandatory = $false)] [bool]$IsTestResult = $false ) # Set prefix $prefix = '' if($LogOutput -eq 'Table'){ $temp = @([pscustomobject]@{message=$Message;logType=$LogType;isTestResult=$IsTestResult}) $script:messages += $temp } elseif($LogOutput -eq 'ADO'){ $prefix = '##[debug]' # Set prefix switch($LogType){ 'Warning' { $prefix = "##vso[task.logissue type=warning]"} 'Error' { $prefix = "##vso[task.logissue type=error]"} 'Failure' { $prefix = "##vso[task.complete result=Failed;]"} } # Add prefix and write to host $Message = $prefix + $Message Write-Output $Message } else{ Write-Output $Message } } #end Write-ToLog <# .SYNOPSIS This module runs through the DAX Query View files that end with .Tests or .Test and output the results. This is based on following the DAX Query View Testing Pattern: https://github.com/kerski/fabric-dataops-patterns/blob/main/DAX%20Query%20View%20Testing%20Pattern/dax-query-view-testing-pattern.md .DESCRIPTION The provided PowerShell script facilitates Data Query View (DQV) testing for datasets within a Fabric workspace. Tests should follow the DAX Query View Testing Pattern that returns a table with 4 column "TestName", "ExpectedValue", "ActualValue", "Passed". For more information, please visit this link: https://github.com/kerski/fabric-dataops-patterns/blob/main/DAX%20Query%20View%20Testing%20Pattern/dax-query-view-testing-pattern.md .PARAMETER TenantId The ID of the tenant where the Power BI workspace resides. .PARAMETER WorkspaceName The name of the Power BI workspace where the datasets are located. .PARAMETER Credential A PSCredential object containing the credentials used for authentication. .PARAMETER DatasetId An optional array of dataset IDs to specify which datasets to test. If not provided, all datasets will be tested. .PARAMETER LogOutput Specifies where the log messages should be written. Options are 'ADO' (Azure DevOps Pipeline), 'Host', or 'Table'. When ADO is chosen: - Any warning will be logged as an warning in the pipeline. An example of a warning would be if a dataset/semantic model has no tests to conduct. - Any failed tests will be logged as an error in the pipeline. - Successfully tests will be logged as a debug in the pipeline. - If at least one failed test occurs, a failure is logged in the pipeline. When Host is chosen, all output is written via the Write-Output command. When Table is chosen: - An Array containing objects with the following properties: - message (String): The description of the event. - logType (String): This is either Debug, Warning, Error, or Failure. - isTestResult (Boolean): This indicates if the event was a test or not. This is helpful for filtering results. .EXAMPLE Run tests for all datasets/semantic models in the workspace and log output using Azure DevOps' logging commands. Invoke-DQVTesting -WorkspaceName "WORKSPACE_NAME" ` -Credential $userCredentials ` -TenantId "TENANT_ID" ` -LogOutput "ADO" .EXAMPLE Run tests for specific datasets/semantic models in the workspace and log output using Azure DevOps' logging commands. Invoke-DQVTesting -WorkspaceName "WORKSPACE_NAME" ` -Credential $userCredentials ` -TenantId "TENANT_ID" ` -DatasetId @("DATASET GUID1","DATASET GUID2") ` -LogOutput "ADO" .EXAMPLE Run tests for specific datasets/semantic models in the workspace and return output in an array of objects (table). Invoke-DQVTesting -WorkspaceName "WORKSPACE_NAME" ` -Credential $userCredentials ` -TenantId "TENANT_ID" ` -DatasetId @("DATASET GUID1","DATASET GUID2") ` -LogOutput "Table" .NOTES Author: John Kerski Dependencies: PowerShell modules Az.Accounts is required. Power BI environment must be a Premium or Fabric capacity and the account must have access to the workspace and datasets. This script depends on FabricPS-PBIP which resides in Microsoft's Analysis Services GitHub site. #> function Invoke-DQVTesting { param ( [Parameter(Mandatory = $true)] [string]$TenantId, [Parameter(Mandatory = $true)] [string]$WorkspaceName, [Parameter(Mandatory = $true)] [PSCredential]$Credential, [Parameter(Mandatory = $false)] [array]$DatasetId = @(), [Parameter(Mandatory = $false)] [ValidateSet('ADO','Host','Table')] [string]$LogOutput = 'ADO' ) # Setup TLS 12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # Check if service principal or username/password $guidRegex = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' $isServicePrincipal = $false if($Credential.UserName -match $guidRegex){# Service principal used $isServicePrincipal = $true } # Convert secure string to plain text to use in connection strings $secureStringPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential.Password) $plainTextPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($secureStringPtr) # Set Fabric Connection Try{ if($isServicePrincipal){ Set-FabricAuthToken -servicePrincipalId $Credential.UserName ` -servicePrincipalSecret $plainTextPwd ` -tenantId $TenantId -reset } else{ # User account Set-FabricAuthToken -credential $Credential -tenantId $TenantId -reset } }Catch [System.Exception]{ $errObj = ($_).ToString() Write-ToLog -Message "$($errObj)" -LogType "Error" -LogOutput $LogOutput return @($script:messages) }# End Try # Message Table $script:messages = @() # Retrieve workspace name using filter capability Try{ $workspaceObj = Get-FabricWorkspace -workspaceName $WorkspaceName $workspaceGuid = $workspaceObj.id }Catch [System.Exception]{ $errObj = ($_).ToString() Write-ToLog -Message "$($errObj)" -LogType "Error" -LogOutput $LogOutput return @($script:messages) }# End Try # Retrieve items from the workspace $workspaceItems = Invoke-FabricAPIRequest -Uri "workspaces/$workspaceGuid/items" -Method Get $datasets = $workspaceItems | Where-Object {$_.type -eq "SemanticModel"} if($DatasetId.Length -gt 0){ # Filter datasets to test specifically base Write-ToLog -Message "--------------------------------------------------" ` -LogType "Debug" ` -LogOutput $LogOutput $datasetsToTest = @() $idsToCheck = @($DatasetId) foreach($id in $idsToCheck){ Write-ToLog -Message "Checking if list of dataset id exist in the workspace. Dataset ID: $($id)" ` -LogType "Debug" ` -LogOutput $LogOutput $temp = $datasets | Where-Object {$_.Id -eq $id} if($temp){# only add to array if id matches $datasetsToTest+=$temp } }# end for each # Reassign $datasets = @($datasetsToTest) if($datasets.Length -eq 0){ Write-ToLog -Message "No datasets found in workspace from this list of dataset IDs: $($opts.DatasetIdsToTest)" ` -LogType "Warning" ` -LogOutput $LogOutput }# end count check }# end check for specific dataset ids passed in # Retrieve item.metadata.json files so we can map dataset names in the service # with the name in the metadata files # March 2024 update - Handle updates for .SemanticModel and .platform changes $metadataObjs = @() $metadataDS = @(Get-ChildItem -Path "*.Dataset/item.metadata.json" -Recurse) ` + @(Get-ChildItem -Path "*/*.Dataset/item.metadata.json" -Recurse) ` + @(Get-ChildItem -Path "*.SemanticModel/.platform" -Recurse) ` + @(Get-ChildItem -Path "*/*.SemanticModel/.platform" -Recurse) foreach($m in $metadataDS){ # Get Content on metdata $parentFolder = Split-Path -Path $m.FullName $content = Get-Content $m.FullName | ConvertFrom-Json # Handle item.metadata.json if($m.Name -eq 'item.metadata.json'){ # prior to March 2024 release $temp = @([pscustomobject]@{displayName=$content.displayName;FolderPath=$ParentFolder;}) }else{ $temp = @([pscustomobject]@{displayName=$content.metadata.displayName;FolderPath=$ParentFolder;}) } $metadataObjs += $temp }# end for each # ---------- Identify DAX Queries for Testing ---------- # # Initiate Failure Count $failureCount = 0 foreach($dataset in $datasets){ Write-ToLog -Message "--------------------------------------------------" ` -LogType "Debug" ` -LogOutput $LogOutput Write-ToLog -Message "Attempting to run test files for $($dataset.displayName)" ` -LogType "Debug" ` -LogOutput $LogOutput # Search metdataObjs $result = $metadataObjs | Where-Object {$_.displayName -eq $dataset.displayName} if($result){ # We have a match so see if there are tests to conduct # Identify the DAX Queries that have a .Tests or .Test $testFiles = @(Get-ChildItem -Path "$($result.FolderPath)/DaxQueries" -Recurse | Where-Object {$_ -like "*.Tests.dax" -or $_ -like "*.Test.dax"}) if($testFiles.Count -eq 0){ Write-ToLog -Message "Unable to locate DAX files in this repository. No tests will be conducted." ` -LogType "Warning" ` -LogOutput $LogOutput }else{ # Execute Tests foreach($testFile in $testFiles){ Write-ToLog -Message "Running test file '$($testFile.FullName)'" ` -LogType "Debug" ` -LogOutput $LogOutput # Setup Connection String Information $serverAddress = "$($script:xMLAPrefix)$($WorkspaceName)" $databaseName = $dataset.displayName $userName = $Credential.UserName $connPwd = $plainTextPwd # Create the Analysis Services connection object $conn = New-Object Microsoft.AnalysisServices.AdomdClient.AdomdConnection # Try issue query Try{ # Handle connection string depending on a user account or service principal if($isServicePrincipal){ $conn.ConnectionString = "Provider=MSOLAP;Data Source=$serverAddress;Database=$databaseName;User ID=""app:$($userName)@$($TenantId)"";Password=$connPwd;Integrated Security=ClaimsToken;" } else{ $conn.ConnectionString = "Provider=MSOLAP;Data Source=$serverAddress;Database=$databaseName;User ID=$userName;Password=$connPwd;Integrated Security=ClaimsToken;" } $conn.Open() # Get query from file $query = (Get-Content $testFile.FullName -Raw) # Create the AS command $cmd = New-Object -TypeName Microsoft.AnalysisServices.AdomdClient.AdomdCommand; $cmd.Connection = $conn; $cmd.CommandTimeout = 600; $cmd.CommandText = $query # Fill a dataset object with the result of the cmd $da = new-Object Microsoft.AnalysisServices.AdomdClient.AdomdDataAdapter($cmd) $ds = new-Object System.Data.DataSet $temp = $da.Fill($ds) $rows = $ds.Tables.Rows #Check if Row Count is 0, no test results. if ($rows.Count -eq 0) { $failureCount += 1 Write-ToLog -Message "Query in test file ""($testFile.FullName)"" returned no results." ` -LogType "Error" ` -LogOutput $LogOutput }#end check of results # Loop through each result for($i = 0; $i -lt $rows.Count; $i++) { #Extract Values $testName = $rows[$i]."[TestName]" $expectedVal = $rows[$i]."[ExpectedValue]" $actualVal = $rows[$i]."[ActualValue]" $passedStr = $rows[$i]."[Passed]".ToString() if (!$testName -or !$passedStr) { $failureCount += 1 Write-ToLog -Message "Query in test file ""$($testFile.FullName)"" did not have test mandatory columns 'TestName', 'Passed')." ` -LogType "Error" ` -LogOutput $LogOutput } else { $passed = [bool]::Parse($passedStr) if (-not $passed) { $failureCount += 1 Write-ToLog -Message "FAILED!: Test ""$($testName)"" for semantic model: $($databaseName). Expected: $($expectedVal) != $($actualVal)" ` -LogType "Error" ` -LogOutput $LogOutput ` -IsTestResult $true } else { Write-ToLog -Message """$($testName)"" passed. Expected: $($expectedVal) == $($actualVal)" ` -LogType "Debug" ` -LogOutput $LogOutput ` -IsTestResult $true }# end check not passed }# end check on test name and passed }#end for loop }Catch [System.Exception]{ $errObj = ($_).ToString() Write-ToLog -Message "$($errObj)" ` -LogType "Error" ` -LogOutput $LogOutput $failureCount +=1 }Finally{ #close your connection $conn.Close(); $conn = $null; }# end try }# end for each test file }# end on test file counts }# end check metadata exists in this file structure for the dataset in the workspace else { Write-ToLog -Message "No test DAX queries for dataset '$($dataset.displayName)'." ` -LogType "Debug" ` -LogOutput $LogOutput } }# end foreach dataset if($LogOutput -eq "ADO"){ if($failureCount -gt 0){ Write-ToLog -Message "Number of Failed Tests: $($failureCount)." ` -LogType "Failure" ` -LogOutput $LogOutput } } return $script:messages } Export-ModuleMember -Function Invoke-DQVTesting |