チュートリアル: PowerShell を使用して複数の EVALUATE ステートメントを実行する

このチュートリアルでは、PowerShell を使用して、複数の ステートメントを含む 1 つの EVALUATE要求を送信し、複数の結果セットの Apache Arrow 応答を解析します。 このパターンを使用すると、PowerShell オートメーション スクリプトから 1 回のラウンド トリップで複数の関連する結果セットを取得できます。

クエリ本文に 3 つの EVALUATE ステートメントを含む 1 つの HTTP POST 要求と、同じ順序で 3 つの結果セットを含む方向 IPC 応答を示す図。

1 つの要求で複数の EVALUATE ステートメントを送信する理由

DAX クエリの実行 API は、複数のquery ステートメントを含むことができる 1 つのEVALUATE文字列を受け入れます。 各ステートメントはそれぞれ独自の結果セットを返し、応答本文は、宣言された順序で EVALUATE ステートメントごとに 1 つの Arrow IPC ストリームを連結したものです。 関連するクエリを一緒に送信すると、追加のMicrosoft Entra トークン検証や DAX エンジンの初期化など、個別の HTTP 呼び出しの要求ごとのオーバーヘッドが回避されます。 1 つの要求で複数の EVALUATE ステートメントを送信すると、要求の調整の影響を軽減するのにも役立ちます。 Power BI、セマンティック モデルのクエリ操作では、呼び出し元がユーザーごとに 1 分あたり 120 個のクエリ要求に制限されます。

構築するもの

1 つの PowerShell スクリプトでは、次の操作を行います。

  1. Microsoft Entraアクセス トークンを取得します。
  2. queryに 3 つのEVALUATE ステートメントが含まれる要求本文を作成します。
  3. リクエストを送信し、生の Arrow IPC レスポンスストリームをキャプチャします。
  4. EVALUATEステートメントごとに 1 つの結果セットに応答を解析します。
  5. 各結果セットを PowerShell オブジェクトとして表示します。

前提条件

  • PowerShell 7.4 以降。 Windows PowerShell 5.1 はサポートされていません。このチュートリアルで使用するApache.Arrow パッケージは、PowerShell 5.1 に含まれるSystem.Memory アセンブリと競合するためです。
  • 少なくとも 1 つのセマンティック モデルを持つ Premium またはFabric容量のPower BI ワークスペース。
  • セマンティック モデルのビルドおよび読み取りのアクセス許可を持つ。
  • 認証用の MicrosoftPowerBIMgmt モジュール。 コマンドレットはMicrosoftのファースト パーティ Power BI クライアント アプリを使用するため、Microsoft Entraに独自のアプリを登録する必要はありません。
  • 応答を逆シリアル化するための Apache.Arrow および Apache.Arrow.Compression .NET ライブラリ。 DAX クエリの実行 REST API は、LZ4 フレーム圧縮で方向バッファーを圧縮するため、 Apache.Arrow.Compression とその依存関係 (K4os.Compression.LZ4K4os.Compression.LZ4.StreamsK4os.Hash.xxHashZstdSharp.Port) が必要です。 次の手順では、それらをダウンロードする方法を示します。
  • Power BI管理ポータルで有効になっている次のテナント設定。
    • データセット実行クエリ REST API ( [開発者設定] の下)。
    • オンプレミスのセマンティック モデルで XMLA エンドポイントと Excelでの分析を許可 (統合設定)。

winget を使用して PowerShell 7.4 以降をインストールします。

winget install --id Microsoft.PowerShell --source winget

インストール後、 pwshを使用して新しいシェルを起動します。 このチュートリアルの残りのコマンドは、そのセッションから実行します。

MicrosoftPowerBIMgmt モジュールをインストールします。 -Force フラグは、PowerShell ギャラリー の信頼されていないリポジトリに関する確認プロンプトを承諾します。

Install-Module -Name MicrosoftPowerBIMgmt -Scope CurrentUser -Force

必要な NuGet パッケージをダウンロードし、そのアセンブリを C:\Tools\Apache.Arrow\に抽出します。 .nupkg ファイルは ZIP アーカイブであるため、Expand-Archive直接動作します。 このループでは、各パッケージ内で最も高い netX.0 ターゲット フォルダーが選択されるため、パッケージが新しいターゲットを発行するにつれてアセンブリの互換性が維持されます。

$dest = "C:\Tools\Apache.Arrow"
New-Item -ItemType Directory -Force -Path $dest | Out-Null

$packages = @(
    "Apache.Arrow",
    "Apache.Arrow.Compression",
    "K4os.Compression.LZ4",
    "K4os.Compression.LZ4.Streams",
    "K4os.Hash.xxHash",
    "ZstdSharp.Port"
)

foreach ($pkg in $packages) {
    $nupkg  = Join-Path $env:TEMP "$pkg.nupkg"
    $expand = Join-Path $env:TEMP $pkg
    if (Test-Path $expand) { Remove-Item $expand -Recurse -Force }

    Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/$pkg" -OutFile $nupkg
    Expand-Archive -Path $nupkg -DestinationPath $expand -Force

    $libDirs = Get-ChildItem (Join-Path $expand "lib") -Directory
    $best = $libDirs | Where-Object { $_.Name -match "^net\d" } |
            Sort-Object Name -Descending | Select-Object -First 1
    if (-not $best) {
        $best = $libDirs | Sort-Object Name -Descending | Select-Object -First 1
    }

    Get-ChildItem (Join-Path $best.FullName "*.dll") |
        Copy-Item -Destination $dest -Force
}

1 - 認証

対話形式でPower BI サービスにサインインし、アクセス トークンを抽出します。 Connect-PowerBIServiceAccount コマンドレットでは、Microsoft Entraに独自のアプリを登録する必要はありません。

Connect-PowerBIServiceAccount -WarningAction SilentlyContinue
$accessToken = (Get-PowerBIAccessToken).Authorization -replace '^Bearer\s+',''

2 - 複数の EVALUATE ステートメントを使用して要求を作成する

ワークスペースとセマンティック モデルのターゲットを定義します。 次に、要求本文をビルドします。 query プロパティは、空白行で区切られた 3 つのEVALUATE ステートメントを含む 1 つの文字列です。

$groupId   = "YOUR_WORKSPACE_ID"
$datasetId = "YOUR_DATASET_ID"

$query = @"
EVALUATE
ROW("RowCount", COUNTROWS('Sales'))

EVALUATE
TOPN(10, 'Sales', 'Sales'[Amount], DESC)

EVALUATE
SUMMARIZECOLUMNS(
    'Date'[Year],
    "TotalSales", SUM('Sales'[Amount]))
"@

$body = @{
    query                  = $query
    resultsetRowcountLimit = 500000
} | ConvertTo-Json

3 - 要求を送信し、生の応答ストリームをキャプチャする

POST 要求を送信し、応答本文をバイナリ ストリームとして読み取ります。 HttpWebRequestInvoke-RestMethod、またはInvoke-PowerBIRestMethodではなく、Invoke-WebRequestを使用します。 応答はバイナリの Arrow IPC ストリームです。 上位レベルの PowerShell コマンドレットでは、応答本文がテキストとして解釈され、バイナリ コンテンツが破損します。 HttpWebRequest は未変更の生ストリームを返します。

$url = "https://api.powerbi.com/v1.0/myorg/groups/$groupId" +
       "/datasets/$datasetId/executeDaxQueries"

$request = [System.Net.HttpWebRequest]::Create($url)
$request.Method      = "POST"
$request.ContentType = "application/json"
$request.Accept      = "application/vnd.apache.arrow.stream"
$request.Timeout     = 180000   # milliseconds
$request.Headers.Add("Authorization", "Bearer $accessToken")

$bodyBytes     = [System.Text.Encoding]::UTF8.GetBytes($body)
$requestStream = $request.GetRequestStream()
$requestStream.Write($bodyBytes, 0, $bodyBytes.Length)
$requestStream.Close()

$response       = $request.GetResponse()
$responseStream = $response.GetResponseStream()

# Buffer the response into memory so the parser can iterate over multiple Arrow IPC streams.
$memoryStream = New-Object System.IO.MemoryStream
$responseStream.CopyTo($memoryStream)
$responseStream.Close()
$response.Close()
$memoryStream.Position = 0

4 - 複数結果セットの応答を解析する

応答本文は、 EVALUATE ステートメントごとに 1 つの Apache Arrow IPC ストリームを連結したものです。 PowerShell には Arrow パーサーが付属していないため、この手順では、Apache.Arrowで追加された小さなインライン C# ヘルパーを介してAdd-Type .NET ライブラリを読み込みます。 C# でストリーム ループ ロジックを保持すると、呼び出しサイトが短く保たれ、PowerShell スクリプトで反復処理できる結果セットの一覧が返されます。 ヘルパーは、ストリームの終わりのマーカーの後に新しい ArrowStreamReader を開くので、同じループが応答内の任意の数の結果セットを処理します。

Add-Type -Path "C:\Tools\Apache.Arrow\Apache.Arrow.dll"
Add-Type -Path "C:\Tools\Apache.Arrow\Apache.Arrow.Compression.dll"

# Reference the full .NET reference set that ships with PowerShell 7 so the
# inline C# below can resolve BCL types such as List<T> and Dictionary<,>.
$refs  = Get-ChildItem "$PSHOME\ref\*.dll" | ForEach-Object FullName
$refs += Get-ChildItem "C:\Tools\Apache.Arrow\*.dll" | ForEach-Object FullName

Add-Type -ReferencedAssemblies $refs -IgnoreWarnings -WarningAction SilentlyContinue -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.IO;
using Apache.Arrow;
using Apache.Arrow.Compression;
using Apache.Arrow.Ipc;

public class DaxResultSet
{
    public List<string> ColumnNames = new List<string>();
    public List<Dictionary<string, object>> Rows =
        new List<Dictionary<string, object>>();
}

public static class DaxMultiResultReader
{
    public static List<DaxResultSet> ReadAll(Stream stream)
    {
        var results = new List<DaxResultSet>();
        var codecFactory = new CompressionCodecFactory();
        while (stream.Position < stream.Length)
        {
            var rs = new DaxResultSet();
            bool gotSchema = false;
            using (var reader = new ArrowStreamReader(stream, codecFactory, leaveOpen: true))
            {
                RecordBatch batch;
                while ((batch = reader.ReadNextRecordBatch()) != null)
                {
                    using (batch)
                    {
                        if (!gotSchema)
                        {
                            foreach (var f in batch.Schema.FieldsList)
                                rs.ColumnNames.Add(f.Name);
                            gotSchema = true;
                        }
                        for (int r = 0; r < batch.Length; r++)
                        {
                            var row = new Dictionary<string, object>();
                            for (int c = 0; c < batch.ColumnCount; c++)
                                row[rs.ColumnNames[c]] = GetValue(batch.Column(c), r);
                            rs.Rows.Add(row);
                        }
                    }
                }
            }
            if (gotSchema) results.Add(rs);
        }
        return results;
    }

    private static object GetValue(IArrowArray a, int i)
    {
        if (a == null) return null;
        if (a is DictionaryArray da)
        {
            // Resolve the dictionary index, then look up the value in the dictionary.
            int dictIndex;
            switch (da.Indices)
            {
                case Int32Array idx32: if (idx32.IsNull(i)) return null; dictIndex = idx32.GetValue(i).Value;       break;
                case Int16Array idx16: if (idx16.IsNull(i)) return null; dictIndex = idx16.GetValue(i).Value;       break;
                case Int8Array  idx8:  if (idx8.IsNull(i))  return null; dictIndex = idx8.GetValue(i).Value;        break;
                case Int64Array idx64: if (idx64.IsNull(i)) return null; dictIndex = (int)idx64.GetValue(i).Value;  break;
                default: return da.Indices.ToString();
            }
            return GetValue(da.Dictionary, dictIndex);
        }
        if (a is StringArray sa)      return sa.GetString(i);
        if (a is BooleanArray ba)     return ba.IsNull(i) ? (object)null : ba.GetValue(i);
        if (a is Int64Array i64)      return i64.IsNull(i) ? (object)null : i64.GetValue(i);
        if (a is Int32Array i32)      return i32.IsNull(i) ? (object)null : i32.GetValue(i);
        if (a is DoubleArray d)       return d.IsNull(i)   ? (object)null : d.GetValue(i);
        if (a is Decimal128Array dec) return dec.GetValue(i);
        if (a is Date32Array d32)     return d32.GetDateTime(i);
        if (a is Date64Array d64)     return d64.GetDateTime(i);
        if (a is TimestampArray ts)   return ts.GetTimestamp(i);
        return a.ToString();
    }
}
"@

$results = [DaxMultiResultReader]::ReadAll($memoryStream)
Write-Host "Received $($results.Count) result sets."

5 - 各結果セットを操作する

各結果セットを PSCustomObject 行に変換します。 これで、 Where-ObjectGroup-ObjectExport-Csv、またはその他の PowerShell コマンドレットを使用して行をパイプできます。

function ConvertTo-PSObjectRows {
    param([Parameter(Mandatory)] $ResultSet)
    foreach ($row in $ResultSet.Rows) {
        $obj = [ordered]@{}
        foreach ($col in $ResultSet.ColumnNames) { $obj[$col] = $row[$col] }
        [PSCustomObject]$obj
    }
}

$rowCount    = ConvertTo-PSObjectRows -ResultSet $results[0]
$topProducts = ConvertTo-PSObjectRows -ResultSet $results[1]
$yearTotals  = ConvertTo-PSObjectRows -ResultSet $results[2]

$rowCount    | Format-Table
$topProducts | Format-Table
$yearTotals  | Format-Table

各変数は、対応する EVALUATE ステートメントの行を、要求にステートメントが表示される順序で保持します。

Troubleshooting

  • 401 Unauthorized — キャッシュされたトークンの有効期限が切れています。 Connect-PowerBIServiceAccountをもう一度実行して更新し、$accessTokenからGet-PowerBIAccessTokenを再読み取りします。
  • Connect-PowerBIServiceAccount中の MSAL 警告MicrosoftPowerBIMgmtは、警告の重大度で内部トレース メッセージ (たとえば、SetAuthorityUriTryNormalizeRealmMsaDeviceOperationProvider is not available) を出力する古い MSAL.NET をバンドルします。 コマンドレットが Environment / TenantId / UserName ブロックを出力する限り、無視しても安全です。 非表示にするには、 -WarningAction SilentlyContinue渡します。
  • エラー結果セットを伴う HTTP 200 — HTTP リクエストは成功しましたが、Arrow ストリームにはエラーが含まれています。 IsError=trueのスキーマ メタデータを検査し、FaultCodeFaultStringを読み取る。 詳細については、「 DAX クエリの実行 REST API のベスト プラクティス」を参照してください。
  • Invoke-RestMethod は文字化けしたテキストを返します 。この API では、 Invoke-RestMethodInvoke-PowerBIRestMethod、または Invoke-WebRequest を使用しないでください。 応答はバイナリです。手順 3 に示すように、 HttpWebRequest を使用します。
  • Add-Type Apache.Arrow.dllの読み込みに失敗する — Windows PowerShell 5.1 では、Apache.Arrow パッケージがインボックス System.Memory アセンブリと競合します。 PowerShell 7.4 以降を使用します。
  • EVALUATE ステートメントよりも返される結果セットの数が少ない場合は、各EVALUATEステートメントが構文的に単独で有効であることを確認します。 無効な EVALUATE が 1 つあると、API は部分的な複数結果セット応答ではなくエラーを返します。