diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index d1f201360..20780b286 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 10/28/2026 +ms.date: 10/29/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -18,14 +18,16 @@ ms.reviewer: brettwil This article summarizes the features and enhancements in each release of the FinOps toolkit. - +### [Power BI reports](power-bi/reports.md) + +- **Fixed** + - Fixed the storage-based Costs query failing to refresh with `Type mismatch (DISP_E_TYPEMISMATCH 0x80020005)` when a row's SKU details didn't carry a clean numeric VCPUs/vCores value (for example, a Marketplace or reservation purchase row). The value is now coerced to null instead of throwing ([#2297](https://github.com/microsoft/finops-toolkit/issues/2297)).
diff --git a/src/power-bi/queries/ftk_NormalizeSchema.pq b/src/power-bi/queries/ftk_NormalizeSchema.pq index fcd5b6eb5..960eb6409 100644 --- a/src/power-bi/queries/ftk_NormalizeSchema.pq +++ b/src/power-bi/queries/ftk_NormalizeSchema.pq @@ -257,7 +257,7 @@ let {"UsageType", "ImageType", "ServiceType", "VMName", "VMProperties", "VCPUs", "ReservationOrderId", "ReservationId", "VMCapacityReservationId", "AHB", "vCores"}, {"x_SkuUsageType", "x_SkuImageType", "x_SkuType", "tmp_VMName", "x_SkuVMProperties", "tmp_VMvCPUs", "tmp_AddlReservationOrderId", "tmp_AddlReservationId", "x_CapacityCommitmentId", "tmp_SQLAHB", "tmp_SQLvCores"} ), - SkuDetailsDict_types = Table.TransformColumnTypes(SkuDetailsDict_expand, {{"tmp_VMvCPUs", Int64.Type}, {"tmp_SQLvCores", Int64.Type}}), + SkuDetailsDict_types = Table.TransformColumns(SkuDetailsDict_expand, {{"tmp_VMvCPUs", each try Int64.From(_) otherwise null, Int64.Type}, {"tmp_SQLvCores", each try Int64.From(_) otherwise null, Int64.Type}}), // Columns from AddlInfo -- Must be after ResourceName cleanup ResourceMachineName = _swapCol(SkuDetailsDict_types, "tmp_VMName", "x_ResourceMachineName", each if _isNotBlank([tmp_VMName]) then [tmp_VMName] else null), diff --git a/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl b/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl index dd1eccee1..54d939b4d 100644 --- a/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl +++ b/src/power-bi/storage/Shared.Dataset/definition/tables/Costs.tmdl @@ -1685,14 +1685,15 @@ table Costs // TODO: Add SQL AHB handling AHB = Table.RemoveColumns( Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn(Table.AddColumn( - Table.TransformColumnTypes( + Table.TransformColumns( Table.ExpandRecordColumn( Table.ReplaceErrorValues(Table.AddColumn(ExtractedTags, "tmp_SkuDetails", each [x_SkuDetailsDictionary]), {{"tmp_SkuDetails", null}}), "tmp_SkuDetails", {"UsageType", "ImageType", "ServiceType", "VMName", "VMProperties", "VCPUs", "ReservationOrderId", "ReservationId", "VMCapacityReservationId", "AHB", "vCores", "ServerSku"}, {"x_SkuUsageType", "x_SkuImageType", "x_SkuType", "tmp_VMName", "x_SkuVMProperties", "tmp_VMvCPUs", "tmp_AddlReservationOrderId", "tmp_AddlReservationId", "x_CapacityCommitmentId", "tmp_SQLAHB", "tmp_SQLvCores", "tmp_ServerSku"} ), - {{"tmp_VMvCPUs", Int64.Type}, {"tmp_SQLvCores", Int64.Type}} + // Coerce to Int64, degrading any non-numeric/unexpected VCPUs/vCores value (e.g. on a Marketplace, reservation, or other non-VM/SQL charge row) to null instead of throwing a type mismatch error on refresh + {{"tmp_VMvCPUs", each try Int64.From(_) otherwise null, Int64.Type}, {"tmp_SQLvCores", each try Int64.From(_) otherwise null, Int64.Type}} ), "x_ResourceMachineName", each if _isNotBlank([tmp_VMName]) then [tmp_VMName] else null), "x_SkuCoreCount", each if [tmp_VMvCPUs] <> null then [tmp_VMvCPUs] else if [tmp_SQLvCores] <> null then [tmp_SQLvCores] else null, Int64.Type), diff --git a/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1 b/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1 new file mode 100644 index 000000000..3c9d20536 --- /dev/null +++ b/src/powershell/Tests/Lint/PowerQuerySkuDetailsCasts.Tests.ps1 @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + Lint rule: values extracted from the free-form x_SkuDetails JSON blob must not be cast with an unguarded + Table.TransformColumnTypes. + + The blob's shape varies by charge type (VM usage, Marketplace, reservation purchase, etc.), so VCPUs / vCores + are not always a clean number. An unguarded cast produces an Error cell, which Power BI surfaces on refresh as + "OLE DB or ODBC error: Type mismatch (DISP_E_TYPEMISMATCH 0x80020005)" (issue #2297). Nothing in CI runs + Power Query, so this static check keeps the guarded form (try ... otherwise null) from regressing. +#> + +Describe 'PowerQuerySkuDetailsCasts' { + + BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path + $files = @( + Get-ChildItem -Path (Join-Path $repoRoot 'src/power-bi') -Include '*.tmdl', '*.pq' -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { (Get-Content -Path $_.FullName -Raw) -match 'tmp_VMvCPUs|tmp_SQLvCores' } | + ForEach-Object { @{ FullName = $_.FullName; RelPath = $_.FullName.Substring($repoRoot.Length + 1).Replace('\', '/') } } + ) + } + + It 'Finds at least one dataset that extracts VCPUs/vCores' { + $files.Count | Should -BeGreaterThan 0 + } + + It 'Does not cast tmp_VMvCPUs / tmp_SQLvCores with an unguarded Table.TransformColumnTypes in ' -ForEach $files { + $text = Get-Content -Path $FullName -Raw + $unguarded = [regex]::Matches($text, '\{\s*"tmp_(VMvCPUs|SQLvCores)"\s*,\s*Int64\.Type\s*\}') + $unguarded.Count | Should -Be 0 -Because 'an unparseable VCPUs/vCores value must degrade to null (try ... otherwise null), not fail the refresh' + } + + It 'Guards the tmp_VMvCPUs / tmp_SQLvCores cast with try ... otherwise null in ' -ForEach $files { + $text = Get-Content -Path $FullName -Raw + $text | Should -Match '"tmp_VMvCPUs",\s*each try [^,]+ otherwise null' + $text | Should -Match '"tmp_SQLvCores",\s*each try [^,]+ otherwise null' + } +}