TripPin 第 7 部分 - 使用 M 类型的高级架构

注意

此内容当前引用了 Visual Studio 中用于单元测试的旧实现中的内容。 内容将在不久的将来更新,以涵盖新的 Power Query SDK 测试框架

本教程分为多个部分,介绍如何针对 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];

选择在项目上运行将评估所有“事实”,并提供如下所示的报告输出:

初始单元测试。

利用测试驱动开发的一些原则,现在可添加当前失败的测试,但很快就会重新实施和修复(在本教程结束时)。 具体而言,你将添加一个测试,用于检查返回到“人员”实体中的其中一个嵌套记录(电子邮件)。

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

如果再次运行代码,就会发现测试失败了。

单元测试失败。

现在只需实现此功能,使其正常工作即可。

定义自定义 M 类型

上一课中的架构强制实施方法使用了定义为名称/类型对的“架构表”。 这种方法适用于平展/关系数据,但不支持在嵌套记录/表/列表上设置类型,也不允许跨表/实体重用类型定义。

在 TripPin 案例中,“人员”和“机场”实体中的数据包含结构化列,甚至共享类型 (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.pqm 文件中找到 Table.ChangeType 函数的完整代码列表。

注意

为提高灵活性,该函数可用于表和记录列表(表在 JSON 文档中就是这样表示的)。

然后,需要更新连接器代码,将 schema 参数从 table 更改为 type,并在 GetEntity 中添加对 Table.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 text,而不是 type any

再次运行单元测试会显示它们现在都通过了。

单元测试成功。

将普通代码重构为独立文件

注意

M 引擎将改进对将来引用外部模块/通用代码的支持,但在此之前,这种方法应该可以满足需要。

此时,你的扩展几乎具有与 TripPin 连接器代码一样多的“通用”代码。 将来,这些常见函数要么成为内置标准函数库的一部分,要么可以从另一个扩展引用。 目前,你可以按以下方式重构代码:

  1. 将可重用函数移动到单独的文件 (.pqm) 中。
  2. 将文件的“生成操作”属性设置为“编译”,以确保在生成过程中将其包含在扩展文件中。
  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 部分 - 添加诊断