第二章的主题为在最大层数MaxLv未知的情况下构建和展开路径图,在开始之前需要讲解如何使用生成数据(第一章节的内容)的附件。首先,请把附件中Data文件夹解压至C:\中。然后,请按以下表格的指示更新文件夹中每一个工作簿的A2单元格后按下快捷键Alt+A+R+A:
| 工作簿名称 | 工作表名称 | 旧值 | 新值 |
| DB1.xlsx | Row | 4 | 4 |
| DB2.xlsx | Row | 4 | 100 |
| DB3.xlsx | Row | 4 | 1,000 |
| DB4.xlsx | Row | 4 | 10,000 |
| DB5.xlsx | Row | 4 | 100,000 |
| DB6.xlsx | Row | 4 | 1,000,000 |
完成更新后的DB5.xlsx和DB6.xlsx的行数比较多,如果使用这两个工作簿作为数据源建议使用64位的Excel2016或者更新的版本。打开DB1.xlsx并激活Data工作表,如下表格为当前工作表A1:B5单元格的值:

上图所示的隶属关系意味着最大层数MaxLv为4, 行数Row为4, 以及组数为1.为了方便讲解代码,将以上图所表达的隶属关系为例讲解代码。在此前提下,本文所指的路径图为:
| Path |
| A000001 |
| A000001|B000001 |
| A000001|B000001|C000001 |
| A000001|B000001|C000001|D000001 |
路径图的展开指的是由以上表格到以下表格的转换:
| Lv1 | Lv2 | Lv3 | Lv4 |
| A000001 | A000001 | A000001 | A000001 |
| A000001 | B000001 | B000001 | B000001 |
| A000001 | B000001 | C000001 | C000001 |
| A000001 | B000001 | C000001 | D000001 |
读者可以在附件中的Solution Step by Step.xlsm找到逐步完成以上转换的代码。其中的查询Filename为一文本值,该文本值对应当前载入的数据源名称(DB1.xlsx).

查询Data为一表格,该表格为数据源。

首先,需要把数据源表格Data分为两组,其中一组(TopParent)只含有处于最顶层的成员,其余的成员置于另一组(NonTopParent)。以下为把最顶层的成员分离出来的代码:
let
Source = Table.SelectRows( Data, each [Parent] = [Child] )
in
Source
以上代码的意思为筛选出Parent与Child相等的行出来。以下为把其他成员分为另一组的代码:
let
Source = Table.SelectRows( Data, each [Parent] ˂˃ [Child] )
in
Source
以上代码的意思为筛选出Parent与Child不相等的行出来。不难发现,处于最顶层的成员的路径图就是Child列或者Parent列本身。由于之后用不上Parent列,所以只要把Parent列的名称改为"Path"就做好这层的路径图(查询的名称记为Lv1Path):
let
Source = Table.RenameColumns( TopParent, { "Parent", "Path" } )
in
Source
以下为构建下一层的路径图需要使用的代码(查询的名称记为Lv2Path):
let
InnerJoin=
Table.NestedJoin(
NonTopParent,
"Parent",
Lv1Path,
"Child",
"NTable",
JoinKind.Inner
),
Expansion=
Table.ExpandTableColumn(
InnerJoin,
"NTable",
{"Path"},
{"PrePath"}
),
AddCol=
Table.AddColumn(
Expansion,
"Path",
each
Text.Combine(
{
[PrePath],
"|",
[Child]
}
),
type text
),
RemoveCol=
Table.RemoveColumns(
AddCol,
{ "Parent", "PrePath" }
)
in
RemoveCol
处于第二层的成员的Parent等于处于顶层的成员的Child,代码中的InnerJoin步骤就是利用这一点通过内连接把第二层的成员从NonTopParent表格中找出来。因为第二层的路径图需要基于第一层的路径图才能构造出来,所以需要把内嵌的Lv1Path的Path列展开并重命名为"PrePath"。之后,只要添加一计算列把PrePath的文本与Child的文本连接起来就完成第二层路径图的构造。最后,把接下来用不上的Parent列与PrePath列移除。以下为查询Lv2Path的结果:

不难发现,此时NonTopParent的第一行在接下来的步骤中已经用不上,为了减少接下来内连接中左表的行数,需要使用左反连接把该行移除,以下为对应的代码(查询的名称记为Lv3Lv4):
let
Source=
Table.NestedJoin(
NonTopParent,
"Child",
Lv2Path,
"Child",
"NTable",
JoinKind.LeftAnti
),
RemoveCol=
Table.RemoveColumns(
Source,
"NTable"
)
in
RemoveCol
因为Child列的值不重复,所以在左反连接中设定了Child列为左反连接的键。连接完成后,会多出不需要的NTable列,所以在最后需要使用Table.RemoveColumns()移除该列。以下为查询Lv3Lv4的结果:

以下为构建第三层的路径图需要使用的代码(查询的名称记为Lv3Path):
let
InnerJoin=
Table.NestedJoin(
Lv3Lv4,
"Parent",
Lv2Path,
"Child",
"NTable",
JoinKind.Inner
),
Expansion=
Table.ExpandTableColumn(
InnerJoin,
"NTable",
{"Path"},
{"PrePath"}
),
AddCol=
Table.AddColumn(
Expansion,
"Path",
each
Text.Combine(
{
[PrePath],
"|",
[Child]
}
),
type text
),
RemoveCol=
Table.RemoveColumns(
AddCol,
{ "Parent", "PrePath" }
)
in
RemoveCol
处于第三层的成员的Parent等于处于第二层的成员的Child,代码中的InnerJoin步骤就是利用这一点通过内连接把第三层的成员从Lv3Lv4表格中找出来。因为第三层的路径图需要基于第二层的路径图才能构造出来,所以需要把内嵌的Lv2Path的Path列展开并重命名为"PrePath"。之后,只要添加一计算列把PrePath列的文本与Child列的文本连接起来就完成第三层路径图的构造。最后,把接下来用不上的Parent列与PrePath列移除。以下为查询Lv3Path的结果:

不难发现,此时Lv3Lv4表格的第一行在接下来的步骤中已经用不上,为了减少接下来内连接中左表的行数,需要使用左反连接把该行移除,以下为对应的代码(查询的名称记为Lv4):
let
Source=
Table.NestedJoin(
Lv3Lv4,
"Child",
Lv3Path,
"Child",
"NTable",
JoinKind.LeftAnti
),
RemoveCol=
Table.RemoveColumns(
Source,
"NTable"
)
in
RemoveCol
因为Child列的值不重复,所以在左反连接中设定了Child列为左反连接的键。连接完成后,会多出不需要的NTable列,所以在最后需要使用Table.RemoveColumns()移除该列。以下为查询Lv4的结果:

以下为构建第四层的路径图需要使用的代码(查询的名称记为Lv4Path):
let
InnerJoin=
Table.NestedJoin(
Lv4,
"Parent",
Lv3Path,
"Child",
"NTable",
JoinKind.Inner
),
Expansion=
Table.ExpandTableColumn(
InnerJoin,
"NTable",
{"Path"},
{"PrePath"}
),
AddCol=
Table.AddColumn(
Expansion,
"Path",
each
Text.Combine(
{
[PrePath],
"|",
[Child]
}
),
type text
),
RemoveCol=
Table.RemoveColumns(
AddCol,
{ "Parent", "PrePath" }
)
in
RemoveCol
处于第四层的成员的Parent等于处于第三层的成员的Child,代码中的InnerJoin步骤就是利用这一点通过内连接把第四层的成员从Lv4表格中找出来。因为第四层的路径图需要基于第三层的路径图才能构造出来,所以需要把内嵌的Lv3Path的Path列展开并重命名为"PrePath"。之后,只要添加一计算列把PrePath列的文本与Child列的文本连接起来就完成第四层路径图的构造。最后,把接下来用不上的Parent列与PrePath列移除。以下为查询Lv4Path的结果:

由于最大的层数被假设为未知的,移除内连接左表首行会再进行一次,以下为对应的代码(查询的名称被记为Lv5):
let
Source=
Table.NestedJoin(
Lv4,
"Child",
Lv4Path,
"Child",
"NTable",
JoinKind.LeftAnti
),
RemoveCol=
Table.RemoveColumns(
Source,
"NTable"
)
in
RemoveCol
因为Child列的值不重复,所以在左反连接中设定了Child列为左反连接的键。连接完成后,会多出不需要的NTable列,所以在最后需要使用Table.RemoveColumns()移除该列。以下为查询Lv5的结果:

由于最大的层数被假设为未知的,内连接会再进行一次产生一个空表触发跳出循环的条件,以下为对应的代码(查询的名称被记为Lv5Path):
let
InnerJoin=
Table.NestedJoin(
Lv5,
"Parent",
Lv4Path,
"Child",
"NTable",
JoinKind.Inner
),
Expansion=
Table.ExpandTableColumn(
InnerJoin,
"NTable",
{"Path"},
{"PrePath"}
),
AddCol=
Table.AddColumn(
Expansion,
"Path",
each
Text.Combine(
{
[PrePath],
"|",
[Child]
}
),
type text
),
RemoveCol=
Table.RemoveColumns(
AddCol,
{ "Parent", "PrePath" }
)
in
RemoveCol
处于第五层的成员的Parent等于处于第四层的成员的Child,代码中的InnerJoin步骤就是利用这一点通过内连接把第五层的成员从Lv5表格中找出来。但是因为Lv5表格是个空表,所以内连接的结果也是一个空表。之后的列展开,添加计算列和移除计算列能成功进行,不过结果也是一个空表。以下为的查询Lv5Path的结果:

要对Lv1Path进行向右展开,首先需要提取Path列使之转化为串列,然后利用List.Repeat()使{"A0000001"}转化为{"A0000001","A0000001","A0000001","A0000001"},以下为对应的代码和结果(查询的结果记为Lv1List):
let
Source = Table.Column( Lv1Path, "Path" ),
Repetition = List.Repeat( Source, 4 )
in
Repetition

要对Lv2Path进行向右展开,首先需要提取Path列使之转化为串列,之后通过深化取出第一项的值"A0000001|B0000001",并通过Text.Split()把该值转化为{"A0000001","B0000001"}。然后,利用List.Repeat()使{"A0000001","B0000001"}转化为{"A0000001","B0000001","A0000001","B0000001","A0000001","B0000001"}后利用List.Alternate()使之转换为{"A0000001","B0000001","B0000001","B0000001"}。以下为对应的代码(查询的结果记为Lv2List):
let
Source = Table.Column( Lv2Path, "Path" ),
ToList = Text.Split( Source{0}, "|" ),
Repetition = List.Repeat( ToList, 3 ),
Outcome = List.Alternate( Repetition, 1, 1, 2 )
in
Outcome
要对Lv3Path进行向右展开,首先需要提取Path列使之转化为串列,之后通过深化取出第一项的值"A0000001|B0000001|C000001",并通过Text.Split()把该值转化为{"A0000001","B000001", "C0000001"}。然后,利用List.LastN()把该串列转化为{"C0000001"},最后把{"A0000001","B000001", "C0000001"}与{"C0000001"}连接起来得到{"A0000001","B000001", "C0000001","C0000001"},以下为对应的代码(查询的名称记为Lv3List):
let
Source = Table.Column( Lv3Path, "Path" ),
ToList = Text.Split( Source{0}, "|" ),
LastN = List.LastN( ToList,1 ),
Outcome = List.Combine( { ToList, LastN } )
in
Outcome
要对Lv4Path进行向右展开,首先需要提取Path列使之转化为串列,之后通过深化取出第一项的值"A0000001|B0000001|C000001|D0000001",并通过Text.Split()把该值转化为{"A0000001","B000001", "C0000001","D0000001}。以下为对应的代码(查询的名称记为Lv4List):
let
Source = Table.Column( Lv4Path, "Path" ),
ToList = Text.Split( Source{0}, "|" )
in
ToList
最后只要把{Lv1List, Lv2List, Lv3List, Lv4List}代入Table.FromRows()就能得到目标表格。以下为对应的代码(查询的名称记为Outcome):
let
Source=
Table.FromRows(
{
Lv1List,
Lv2List,
Lv3List,
Lv4List
},
type table [Lv1=text, Lv2=text, Lv3=text, Lv4=text]
)
in
Source
接下来要引入循环把以上孤立的转换联系起来,对应的代码可以在附件的Solution1.xlsm中找到。首先还是要把处于顶层的成员找出来(查询的名称记为FilterLv1):
let
Source=
Table.RenameColumns(
Table.SelectRows(
Data,
each [Child] = [Parent]
),
{ "Parent", "Path" }
)
in
Source
以上代码的意思为把Child列与Parent列相同的成员从Data表中找出,然后把筛选结果的Parent列的列名称改为"Path"。之后,需要把不处于最顶层的成员从Data表中找出来:
let
Source=
Table.SelectRows(
Data,
each [Child] ˂˃ [Parent]
)
in
Source
不处于最顶层的成员的Parent列与Child列不相等,Table.SelectRows()就是利用这一逻辑关系把这些成员归为一组。完成分组后就可以开始路径图的构建,以下为对应的代码(查询的名称记为Path):
let
SList=
List.Buffer(
List.Generate(
()=˃{ FilterNonLv1, FilterLv1 },
each not Table.IsEmpty( _{1} ),
each
if
Table.IsEmpty( _{0} )
then
{ #table( {}, {} ), #table( {}, {} ) }
else
let
TempRecordList=
List.Buffer(
Table.TransformRows(
Table.RemoveColumns(
Table.ExpandTableColumn(
Table.NestedJoin(
_{0},
"Parent",
_{1},
"Child",
"NTable",
JoinKind.Inner
),
"NTable",
{"Path"}
),
"Parent"
),
(x)=˃
Record.TransformFields(
x,
{
"Path",
each
Text.Combine(
{
_,
"|",
x[Child]
}
)
}
)
)
)
in
{
Table.RemoveMatchingRows(
_{0},
TempRecordList,
"Child"
),
Table.FromRecords(
TempRecordList,
type table [Child=text, Path=text]
)
},
each _{1}
)
)
in
SList
如下图所示,以上代码对应之前逐步转换的Lv1Path到Lv5Path。

虽然本文假设的MaxLv为4,但是由于右表(左右是相对内连接而言)为空时才会触发List.Generate()跳出循环,实际循环次数为MaxLv+1=5。为了减少在最后一次循环中右表产生空表所需要的时间,以上代码引入了if..then..else使左表为空时强制右表为空。因为通过Table.NestedJoin()实现的左反连接所需要的运行时间较长,因此以上代码使用了Table.RemoveMatchingRows()实现左反连接的效果。但是这个函数的第二个参数只接受结构为{[]..[]}的复合串列,为了减少重复运算以上代码使用了Table.TransformRows()代替Table.AddColumns()完成路径图的构建。为了减少代码运行的时间,需要在List.Generate()外面套一个List.Buffer()。出于类似的理由,也需要在Table.TransformRows()外面套一个List.Buffer()。不难发现,查询Path的结果{#table()..#table()}的元素个数等于MaxLv,因此可以把该结果代入List.Count()得出MaxLv(查询的名称记为MaxLv):
let
Source = List.Count( Path )
in
Source
得到最大层数后就可以对构造的路径图进行展开,以下为对应的代码(查询的名称记为FillRight):
let
Source=
List.Transform(
List.Numbers( 0, MaxLv ),
(x)=˃
if
x = 0
then
let
TempList1=
List.Transform(
List.Split(
Table.Column( Path{ 0 }, "Path" ),
1
),
each List.Buffer( _ )
)
in
List.Transform(
TempList1,
(w)=˃ List.Repeat( w, MaxLv )
)
else if
x ˂ MaxLv - 2
then
let
TempValue1 = MaxLv - x,
TempValue2 = x + 1
in
List.Transform(
Table.Column( Path{ x }, "Path" ),
(y)=˃
List.Alternate(
List.Repeat(
Text.Split( y, "|" ),
TempValue1
),
x,
1,
TempValue2
)
)
else if
x = MaxLv - 2
then
List.Transform(
Table.Column( Path{ x }, "Path" ),
(y)=˃
let
TempList2 = Text.Split( y, "|" )
in
List.Combine(
{
TempList2,
List.LastN( TempList2, 1 )
}
)
)
else
List.Transform(
Table.Column( Path{ x }, "Path" ),
(z)=˃ Text.Split( z, "|" )
)
)
in
Source
如下图所示,以上代码对应之前逐步转换的Lv1List至Lv4List。

唯一需要注意的是最顶层路径图的展开,按照正常的思路List.Transform( Path[Path], each List.Repeat( {_}, MaxLv ) )就可以完成展开了,但是运行起来貌似比使用List.Alternate()进行展开还要慢,所以使用了List.Split( Path[Path], 1 )把Path[Path]中的每一个元素化作串列再使用List.Repeat()完成展开。有趣的是,转化得来的串列还需要加上List.Buffer()才能达到减少运行时间的效果。完成路径图的展开后,把查询FillRight的结果带入Table.FromRows就完成所有的转换了:
let
ToTable=
Table.FromRows(
List.Combine( FillRight ),
fnTableType( MaxLv )
)
in
ToTable
以上代码的fnTableType对应的代码为:
( ColCount as number )=˃
let
TableType=
Expression.Evaluate(
Text.Combine(
{
"type table [",
Text.Combine(
List.Transform(
List.Numbers( 1, ColCount ),
each
Text.Combine(
{
"Lv",
Text.From( _ ),
"=text"
}
)
),
","
),
"]"
}
)
)
in
TableType
以上函数,首先构造文本"type table [Lv1=text, Lv2=text, Lv3=text, Lv4=text]",然后把该文本代入Expression.Evaluate()中使之转化为type table [Lv1=text, Lv2=text, Lv3=text]。实际上由于左反连接减少的运行时间小于使用Table.TransformRows()增加的时间,因此移除与左反连接相关的转换,运行时间反而会减少,以下为只使用内连接构造路径图的代码:
let
Source=
List.Buffer(
List.Generate(
()=˃FilterLv1,
each not Table.IsEmpty( _ ),
each
Table.Buffer(
Table.RemoveColumns(
Table.AddColumn(
Table.ExpandTableColumn(
Table.NestedJoin(
FilterNonLv1,
"Parent",
_,
"Child",
"NTable",
JoinKind.Inner
),
"NTable",
{"Path"},
{"PrePath"}
),
"Path",
each
Text.Combine(
{
[PrePath],
"|",
[Child]
}
),
type text
),
{ "Parent", "PrePath" }
)
)
)
)
in
Source
如下图所示,以上代码对应圈中的查询:

以上代码中,由于每一次循环的结果都是一个表格,所以需要在该表格外面套一个Table.Buffer()才能达到减少运行时间的效果。如果对以上两种方法的运行时间感兴趣,可以打开附件Solution1.xlsm(Solution2.xlsm)然后按下Alt+F8再按下Alt+R。如下图所示,使用了左反连接的解法在不同行数的数据中运行所需要的时间为:

如下图所示,仅使用内连接的解法在不同行数的数据中运行所需要的时间为:

目前为止,M语言已经有多达900个函数,相信以上解法一定不是最好的。如果各位M语言爱好者有新的发现,希望能以留言方式与大家分享一下,文本会及时更新。
文件无法下载了,请更新,谢谢了
已经置换文件