TripPin パート 7 - M 型の高度なスキーマ

このマルチパート チュートリアルでは、Power Query 用の新しいデータ ソース拡張機能の作成について説明します。 このチュートリアルは順番に実行することを目的としています。各レッスンは前のレッスンで作成したコネクタに基づいて構築され、コネクタに新しい機能が段階的に追加されます。

このレッスンの内容:

  • M 型を使用してテーブル スキーマを適用する
  • 入れ子になったレコードとリストの型を設定する
  • 再利用と単体テストのためのコードのリファクタリング

前のレッスンで、シンプルな "スキーマ テーブル" システムを使用してテーブル スキーマを定義しました。 このスキーマ テーブルのアプローチは、多くの REST API/データ コネクタに対して機能しますが、完全なデータ セットまたは深い入れ子になったデータ セットを返すサービスで、M 型システムを利用するこのチュートリアルのアプローチからメリットが得られる可能性があります。

このレッスンでは、次の手順について説明します。

  1. 単体テストの追加。
  2. カスタム M 型を定義する。
  3. 型を使用してスキーマを強制する。
  4. 共通コードを個別のファイルにリファクタリングする。

単体テストの追加

高度なスキーマ ロジックの使用を開始する前に、不注意で何かを壊す可能性を減らすために、コネクタに一連の単体テストを追加します。 単体テストは次のように動作します。

  1. UnitTest サンプル から共通コードを TripPin.query.pq ファイルにコピーします。
  2. TripPin.query.pqファイルの先頭にセクション宣言を追加します。
  3. 共有 レコード (TripPin.UnitTest と呼ばれます) を作成します。
  4. 各テストに対して Fact を定義します。
  5. すべてのテストを実行するには、Facts.Summarize() を呼び出します。
  6. プロジェクトが Visual Studio で実行されるときに確実に評価されるように、前の呼び出しを共有値として参照します。
section TripPinUnitTests;

shared TripPin.UnitTest =
[
    // Put any common variables here if you only want them to be evaluated once
    RootTable = TripPin.Contents(),
    Airlines = RootTable{[Name="Airlines"]}[Data],
    Airports = RootTable{[Name="Airports"]}[Data],
    People = RootTable{[Name="People"]}[Data],

    // Fact(<Name of the Test>, <Expected Value>, <Actual Value>)
    // <Expected Value> and <Actual Value> can be a literal or let statement
    facts =
    {
        Fact("Check that we have three entries in our nav table", 3, Table.RowCount(RootTable)),
        Fact("We have Airline data?", true, not Table.IsEmpty(Airlines)),
        Fact("We have People data?", true, not Table.IsEmpty(People)),
        Fact("We have Airport data?", true, not Table.IsEmpty(Airports)),
        Fact("Airlines only has 2 columns", 2, List.Count(Table.ColumnNames(Airlines))),        
        Fact("Airline table has the right fields",
            {"AirlineCode","Name"},
            Record.FieldNames(Type.RecordFields(Type.TableRow(Value.Type(Airlines))))
        )
    },

    report = Facts.Summarize(facts)
][report];

プロジェクトで実行を選択すると、すべてのファクトが評価され、次のようなレポート出力が得られます。

Initial Unit Test.

テスト駆動開発の一部の原則を使用して、現在失敗しているテストを追加しますが、すぐに再実装および修正します (このチュートリアルの終わりまでに)。 具体的には、People エンティティに戻る入れ子になったレコード (電子メール) のいずれかをチェックするテストを追加します。

Fact("Emails is properly typed", type text, Type.ListItem(Value.Type(People{0}[Emails])))

コードを再度実行すると、テストに失敗するのがわかるはずです。

Unit test with failure.

これが動作するように機能を実装する必要があります。

カスタム M 型を定義する

前のレッスンのスキーマ適用アプローチでは、名前と型のペアとして定義された "スキーマ テーブル" を使用しました。 フラット化された、またはリレーショナル データを操作する場合はうまく機能しますが、入れ子になったレコード/テーブル/リストに対する型の設定はサポートしませんでした。また、テーブル/エンティティ間で型定義を再利用することもできません。

TripPin の場合、People エンティティと Airports エンティティのデータに構造化列が含まれており、住所情報を表す型 (Location) も共有しています。 スキーマ テーブルで名前と型のペアを定義するのではなく、カスタム M 型宣言を使用してこれらの各エンティティを定義します。

言語仕様の M 言語の型について簡単に思い出しましょう。

"型値" はその他の値を "分類する" 値です。 型で分類される値は、その型に "準拠する" とされます。 M 型システムは、次の種類の型で構成されています。

  • プリミティブ値を分類するプリミティブ型 (binarydatedatetimedatetimezonedurationlistlogicalnullnumberrecordtexttimetype) のほか、多数の抽象型 (functiontableanynone) も含まれます。
  • レコード型。これはフィールドの名前や値の型に基づいてレコード値を分類するものです
  • リスト型。これは単一項目の基本型を利用して一覧を分類するものです
  • 関数型。これはそのパラメーターの型に基づいて関数値を分類し、値を返すものです
  • テーブル型。これは列の名前、列の型、キーに基づいてテーブル値を分類するものです
  • null 許容型。これは、基本型によって分類されるすべての値に加え、値 null を分類するものです
  • タイプ型。これは型である値を分類するものです

取得した未加工の JSON 出力 (および/またはサービスの $metadata 内の定義を参照) を使用して、OData 複合型を表す次のレコード型を定義できます。

LocationType = type [
    Address = text,
    City = CityType,
    Loc = LocType
];

CityType = type [
    CountryRegion = text,
    Name = text,
    Region = text
];

LocType = type [
    #"type" = text,
    coordinates = {number},
    crs = CrsType
];

CrsType = type [
    #"type" = text,
    properties = record
];

その構造化された列を表現するのに LocationType から CityTypeLocType が参照されていることに注意してください。

(テーブルとして表す) 上位レベルのエンティティの場合は、"テーブル型" を定義します。

AirlinesType = type table [
    AirlineCode = text,
    Name = text
];

AirportsType = type table [
    Name = text,
    IataCode = text,
    Location = LocationType
];

PeopleType = type table [
    UserName = text,
    FirstName = text,
    LastName = text,
    Emails = {text},
    AddressInfo = {nullable LocationType},
    Gender = nullable text,
    Concurrency = Int64.Type
];

次に、SchemaTable 変数 (エンティティから型へのマッピングの "検索テーブル" として使用する) を更新して、次の新しい型定義を使用します。

SchemaTable = #table({"Entity", "Type"}, {
    {"Airlines", AirlinesType },    
    {"Airports", AirportsType },
    {"People", PeopleType}    
});

型を使用してスキーマを強制する

前のレッスンSchemaTransformTable を使用したように、共通の関数 (Table.ChangeType) を使用してデータにスキーマを適用します。 SchemaTransformTable とは異なり、Table.ChangeType は実際の M テーブル型を引数として受け取り、入れ子になったすべての型にスキーマを "再帰的" に適用します。 署名は次のようになります。

Table.ChangeType = (table, tableType as type) as nullable table => ...

Table.ChangeType 関数の完全なコード リストは、Table.ChangeType.pqm ファイルで確認できます。

Note

柔軟性を高めるために、この関数は、レコードのリスト (JSON ドキュメントでテーブルを表す方法) だけでなく、テーブルにも使用できます。

次に、コネクタ コードを更新して、schema パラメーターを table から type に更新し、GetEntityTable.ChangeType の呼び出しを追加する必要があります。

GetEntity = (url as text, entity as text) as table => 
    let
        fullUrl = Uri.Combine(url, entity),
        schema = GetSchemaForEntity(entity),
        result = TripPin.Feed(fullUrl, schema),
        appliedSchema = Table.ChangeType(result, schema)
    in
        appliedSchema;

GetPage は、スキーマからフィールドの一覧を使用するように更新されますが (結果を取得するときに展開するものの名前を知るため)、実際のスキーマ適用は GetEntity のままです。

GetPage = (url as text, optional schema as type) as table =>
    let
        response = Web.Contents(url, [ Headers = DefaultRequestHeaders ]),        
        body = Json.Document(response),
        nextLink = GetNextLink(body),
        
        // If we have no schema, use Table.FromRecords() instead
        // (and hope that our results all have the same fields).
        // If we have a schema, expand the record using its field names
        data =
            if (schema <> null) then
                Table.FromRecords(body[value])
            else
                let
                    // convert the list of records into a table (single column of records)
                    asTable = Table.FromList(body[value], Splitter.SplitByNothing(), {"Column1"}),
                    fields = Record.FieldNames(Type.RecordFields(Type.TableRow(schema))),
                    expanded = Table.ExpandRecordColumn(asTable, fields)
                in
                    expanded
    in
        data meta [NextLink = nextLink];

入れ子になった型が設定されていることを確認する

PeopleType の定義によって、Emails フィールドがテキストの一覧 ({text}) に設定されるようになりました。 型を正しく適用している場合、単体テストの Type.ListItem の呼び出しから、type any ではなく、type text が返されるようになるはずです。

単体テストを再度実行すると、すべてが合格している状態が示されます。

Unit test with success.

共通コードを個別のファイルにリファクタリングする

Note

M エンジンでは、今後、外部モジュール/共通コードの参照のサポートが強化される予定ですが、それまではこの方法で対応できるはずです。

この時点では、拡張機能には、TripPin コネクタ コードとほぼ同じ "一般的な" コードが含まれます。 今後、これらの一般的な関数は組み込みの標準関数ライブラリに含まれるか、別の拡張機能から参照できるようになります。 今のところは、次の方法でコードをリファクタリングします。

  1. 再利用可能な関数を別のファイル (.pqm) に移動します。
  2. ファイルの Build Action プロパティを Compile に設定して、ビルド中に拡張ファイルに含まれるようにします。
  3. Expression.Evaluate を使用してコードを読み込む関数を定義します。
  4. 使用する共通の関数をそれぞれ読み込みます。

これを行うコードは、次のスニペットに含まれています。

Extension.LoadFunction = (fileName as text) =>
  let
      binary = Extension.Contents(fileName),
      asText = Text.FromBinary(binary)
  in
      try
        Expression.Evaluate(asText, #shared)
      catch (e) =>
        error [
            Reason = "Extension.LoadFunction Failure",
            Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
            Message.Parameters = {fileName, e[Reason], e[Message]},
            Detail = [File = fileName, Error = e]
        ];

Table.ChangeType = Extension.LoadFunction("Table.ChangeType.pqm");
Table.GenerateByPage = Extension.LoadFunction("Table.GenerateByPage.pqm");
Table.ToNavigationTable = Extension.LoadFunction("Table.ToNavigationTable.pqm");

まとめ

このチュートリアルでは、REST API から取得したデータにスキーマを適用する方法をいくつか改善しました。 現在、コネクタはスキーマ情報をハードコーディングしており、実行時にパフォーマンスの利点がありますが、サービスのメタデータの時間的な変化に適応することができません。 今後のチュートリアルでは、サービスの $metadata ドキュメントからスキーマを推論する、純粋に動的なアプローチに移行します。

このチュートリアルでは、スキーマの変更に加えて、コードの単体テストを追加し、共通のヘルパー関数を個別のファイルにリファクタリングして、全体的な読みやすさを向上しました。

次のステップ

TripPin パート 8 - 診断の追加