DAX 权威指南 | 04 理解计算上下文

现在,您已经学习了DAX语言的基础知识。您知道如何创建计算列和度量值,并且您对DAX中的常用函数有很好的理解。本章将带你进入DAX语言的更高级别:在学习了扎实的DAX语言理论基础之后,您将能够成为一个真正的DAX冠军。

根据当前所获得的知识,您已经可以创建许多有趣的报告,但您需要学习计算上下文以创建更复杂的报告。计算上下文是DAX所有高级功能的基础。

我们想向读者说几句警告:计算上下文的概念很简单,您很快就会学到并理解它。然而,您需要彻底了解几个微妙的注意事项和细节。否则,在学习DAX的过程中,你会在某一点上感到迷茫。我们在公共和私人课程中教授DAX给许多用户。我们知道这绝对正常。在某个时刻 ,你感觉公式像魔术一样工作,因为它们起作用,但你不明白为什么。别担心:很多人都会经历这个过程。大多数DAX学生已经达到了这一点,而其他许多学生将来也会达到这一点。这只是意味着他们不够了解计算上下文。那时,很简单:回到这一章,再读一遍,你可能会发现一些新的东西,这是你第一次读的时候错过的。

此外,计算上下文在使用函数CALCULATE时发挥了重要作用,这可能是DAX中功能最强大且难以学习的函数。我们将在第5章“理解CALCULATE和CALCULATETABLE”中介绍CALCULATE,然后在本书的其余部分中使用它。在没有扎实的计算上下文知识的情况下理解CALCULATE是有问题的。另一方面,在不尝试使用CALCULATE的情况下理解计算上下文的重要性几乎是不可能的。因此,根据我们之前编写的书籍的经验,本章下一章将会被反复阅读。

4.1 计算上下文简介

让我们从理解计算上下文是什么开始。所有DAX表达式在一个上下文环境中计算。上下文是计算公式的“环境”。例如,考虑一个非常简单的度量值公式:

[Sales Amount] := SUMX ( Sales, Sales[Quantity] * Sales[UnitPrice] )

您已经知道此公式计算的内容:Sales表中所有数量值乘以价格的总和。您可以将此度量值放在数据透视表中并查看结果,如图4-1所示。

图4-1 度量值[Sales Amount](没有上下文)显示销售额总计。

嗯,这个数字本身看起来并不有趣,是吗?但是,如果你仔细思考,公式就会计算出它应该计算的内容:所有销售额的合计,这是一个没有意义的大数字。当我们使用一些列来切割总计并查看它,这个数据透视表就会变得更有趣。例如,您可以获取产品颜色,将其放在行上,并且数据透视表突然显示一些有趣的业务见解,如图4-2所示。

图4-2 按颜色切片的销售额总计看起来更有趣。

总计仍然存在,但现在它是较小值的合计,每个值,连同所有其他值,都有意义。但是,如果你再仔细思考,你应该注意到这个不可思议的事情:公式并没有计算我们要求的东西。

我们认为该公式意味着“所有销售额的总和。”但是在数据透视表的每个单元格内,公式不是计算所有销售额的总和,而是仅计算具有特定颜色的产品的销售总和。然而,我们从未指定计算必须在数据模型的子集上工作。换句话说,该公式未指定它可以处理数据子集。

为什么公式在不同的单元格中计算不同的值?答案非常简单:因为在计算上下文环境中DAX计算公式。您可以将公式的计算上下文视为DAX计算公式的单元格周围区域。

因为product颜色在行上,数据透视表中的每一行都可以看到,从整个数据库中,只有特定颜色的product的子集。这是公式的周围区域,即在公式计算之前应用于数据库的一组筛选器。当公式计算所有销售额的总和时,它不会在整个数据库中计算它,因为它没有查看所有行的选项。当DAX在值为White的行中的计算公式时,只有白色产品是可见的,因此,它只考虑与白色产品相关的销售。所以,当计算透视表中的一行仅显示白色产品时,所有销售额的总和将成为白色产品的所有销售额的总和。

任何DAX公式都指定一个计算,但DAX在上下文中计算公式,该上下文定义最终计算的值。公式总是相同,但值不同,因为DAX根据不同的数据子集对其进行计算。

公式在其定义方式中表现的唯一情况是总计。在该级别,因为没有发生筛选,所以整个数据库都是可见的。

备注:
在这些示例中,为简单起见,我们使用数据透视表。显然,您也可以使用查询定义计算上下文,您将在以后的章节中了解有关它的更多信息。现在,最好保持简单并仅考虑数据透视表,以便对概念有一个简化和可视化的理解。

现在让我们把年份放在列上,使数据透视表更加有趣。该报告现在如图4-3所示。

图4-3 现在SalesAmount的总和按颜色和年份进行切片。

此时游戏规则应该清楚:即使公式始终相同,每个单元格现在也有不同的值,因为数据透视表的行和列选择都定义了上下文。事实上,2008年白色产品的销售额与2007年白色产品的销售额不同。此外,因为您可以在行和列中放置多个字段,所以最好说行上的字段集和列上的字段集定义上下文。图4-4使这一点更加明显。

图4-4 上下文由行和列上的字段集定义。

每个单元格都有不同的值,因为行上有两个字段:颜色和品牌名。行和列上的完整字段集定义了上下文。例如,图4-4中突出显示的单元格的上下文对应于黑色、品牌Contoso和Calendar Year 2007。

备注:
字段是在行上还是在列上(或者在切片器和/或页面筛选器上,或者您可以使用查询创建的任何其他类型的筛选器)并不重要。所有这些筛选器都有助于定义单个上下文,DAX用它来计算公式。在行或列上放置一个字段只会产生一些可视效果,但DAX计算值的方式没有任何变化。

我们现在看看全貌。在图4-5中,我们在切片器上添加了产品类别,并在过滤器上添加了月份名称,我们选择了12月。

图4-5在 典型报告中,上下文以多种方式定义,包括切片器和筛选器。

很明显,每个单元格中计算的值都有一个由行、列、切片器和筛选器定义的上下文。所有这些筛选器都有助于定义DAX在公式计算之前应用于数据模型的上下文。此外,重要的是要知道并非所有单元都具有相同的筛选器组,不仅在值方面,而且在字段方面。例如,列上的总计仅包含类别、月份和年份的筛选器,但它不包含颜色和品牌的筛选器。颜色和品牌的字段在行上,并且不会过滤总计。这同样适用于数据透视表中的颜色小计:对于那些单元格,制造商没有筛选器,来自行的唯一有效筛选器是颜色。

我们将此上下文称为筛选上下文,顾名思义,它是一个筛选表的上下文。根据DAX用于执行其计算的筛选器上下文,您所编写的任何公式都将具有不同的值。尽管这种行为非常直观,但任需要很好地理解。

现在您已经了解了筛选上下文的内容,您知道以下DAX表达式应该被理解为“当前筛选上下文中可见的所有销售额的总和”:

[Sales Amount] := SUMX ( Sales, Sales[Quantity] * Sales[UnitPrice] )

稍后您将学习如何阅读、修改和清除筛选器上下文。截至目前,您已经充分了解筛选上下文始终存在于数据透视表的任何单元格和报表/查询中的任何值这一事实就足够了。您始终需要考虑筛选上下文,以了解DAX如何计算公式。

4.1.1 理解行上下文

筛选上下文是DAX中存在的两个上下文之一。它的伴侣是行上下文,在本节中,您将了解行上下文是什么以及它是如何工作的。

这一次,我们使用不同的公式来研究:

Sales[GrossMargin] = Sales[SalesAmount] - Sales[TotalCost]

您可能会在计算列中编写此表达式,以计算毛利率。只要在计算列中定义此公式,就会得到结果表,如图4-6所示。


图4-6 为表的所有行计算GrossMargin。

DAX计算了表的所有行的公式,并且对于每一行,它按预期计算了不同的值。为了理解行上下文,我们在阅读公式时需要有点迂腐:我们要求减去两列,但是我们从哪里告诉DAX从哪一行获取列的值?您可能会说要使用的行是隐式的。因为它是一个计算列,DAX会逐行计算它,并且对于每一行,它会计算不同的结果。这是正确的,但是,从DAX表达式的角度来看,仍然缺少有关使用哪一行的信息。

实际上,用于执行计算的行不存储在公式中。它由另一种上下文定义:行上下文。当您定义计算列时,DAX从表的第一行开始迭代;它创建了一个包含该行的行上下文并计算了表达式。然后它移动到第二行并再次计算表达式。对于表中的所有行都会发生这种情况,如果您有一百万行,您可以认为DAX创建了一百万行上下文来评估公式一百万次。显然,为了优化计算,这并不是实际情况;否则,DAX将是一种非常慢的语言。
无论如何,从逻辑的角度来看,这正是它的工作原理。

让我们试着说得更精确些。行上下文是始终包含单行的上下文,DAX在创建计算列时自动定义它。您可以使用其他方法创建行上下文,这将在本章后面讨论,但解释行上下文的最简单方法是查看计算列,引擎始终自动创建它。

总有两个上下文
目前为止,您已经了解了行上下文和筛选上下文。它们是DAX中唯一的上下文。因此,它们是修改公式结果的唯一方法。任何公式都将在这两个不同的上下文中进行计算:行上下文和筛选上下文。
我们将这两种上下文称为“计算上下文”,因为它们是改变公式计算方式的上下文,为相同公式提供不同的结果。
这一点非常重要,而且在开始时很难集中注意:总是有两种上下文,公式的结果取决于这两种上下文。在DAX学习路径的这一点上,您可能认为这是显而易见且非常自然的。你可能是对的。然而,在本书的后面,您将发现,如果您不记得这两种上下文的共存关系,那么要理解这些公式将是一个挑战,因为这两种上下文都可以更改公式的结果。

4.2 测试你对上下文的理解

在我们进行关于计算上下文的更复杂的讨论之前,我们想用几个例子来测试你对上下文的理解。请不要立即查看说明;在问题之后停下来并尝试回答它。然后阅读解释以理解它。

4.2.1 在计算列中使用SUM函数

第一个测试非常简单。如果使用此代码在Sales中定义计算列,会发生什么?

Sales[SumOfSalesAmount] = SUM ( Sales[SalesAmount] )

因为它是一个计算列,所以它将逐行计算,对于每一行,您将获得一个结果。你期望看到什么数字?从以下选项中选择一个:

  • 该行的SalesAmount值,即每行的不同值。
  • 所有行的SalesAmount总数,即所有行的相同值。
  • 一个错误;您不能在计算列中使用SUM。

请停止阅读,我们在等你有根据的猜测后再继续。

现在,让我们详细说明DAX计算公式时发生了什么。您已经了解了公式含义:“当前筛选器上下文中显示的所有销售额的总和。”由于这是在计算列中,DAX逐行计算公式。因此,它为第一行创建行上下文,然后调用公式计算并继续迭代整个表。该公式计算当前筛选器上下文中所有销售额值的总和,因此真正的问题是“当前筛选上下文是什么?”答案:它是完整数据库,它是完整的数据库,因为DAX在任何数据透视表或任何其他类型的筛选之外计算公式。事实上,当没有过筛选时,DAX会将其计算为计算列定义的一部分。

即使存在行上下文,SUM也会忽略它。相反,它使用筛选上下文,现在筛选上下文是完整数据库。因此,第二个选项是正确的:您将获得销售额的总计,所有行的值相同,如图4-7所示。

图4-7 计算列中的SUM(Sales [SalesAmount])是针对完整数据库计算的。

这个例子表明这两个上下文同时存在。它们都对一个公式的结果进行处理,但方式不同。计算列中使用的SUM、MIN和MAX等聚合函数仅使用筛选上下文,并忽略行上下文,DAX仅使用行上下文来确定列值。如果您选择了第一个答案,就像许多学生一样,这是完全正常的。关键是,您还没有考虑到这两个上下文正在以不同的方式一起工作来更改公式的结果。第一个答案是最常见的,直觉上是,但它是错误的,现在你知道为什么。

4.2.2 在度量值中使用列

第二个测试有点不同。想象一下,您想要在度量值而不是计算列中定义毛利率的公式。您有一个包含销售额的列,另一列包含产品成本,您可以编写以下表达式:

[GrossMargin] := Sales[SalesAmount] - Sales[ProductCost]

如果您尝试编写此度量值,您会得到什么结果?

  1. 表达式正常,我们需要在报告中测试结果。
  2. 一个错误,你甚至不能创作这个公式。
  3. 您可以定义公式,但在数据透视表或查询中使用时会出错。

和以前一样,停止阅读,考虑答案,然后阅读以下说明。

在公式中,我们使用Sales [SalesAmount],它是一个列名,即Sales表中SalesAmount的值。这个定义缺少什么吗?您应该从前面的参数中回忆一下,此处缺少的信息是从哪里获取SalesAmount的当前值的行。当您在计算列中编写这段代码时,由于行上下文的关系,DAX在计算表达式时知道要使用的行。但是,度量值会发生什么?没有迭代,没有当前行,也就是说,没有行上下文。

因此,第二个答案是正确的。你甚至不能写公式;它在语法上是错误的,当你尝试输入它时会收到错误。

请记住,列本身没有值。相反,它对表的每一行都有不同的值。因此,如果您想要单个值,则需要指定要使用的行。指定要使用的行的唯一方法是行上下文。因为在这个度量值中没有行上下文,所以公式是不正确的,DAX将拒绝它。

在度量值中指定此计算的正确方法是使用聚合函数,如:

[GrossMargin]:= SUM(Sales [SalesAmount]) -  SUM(Sales [ProductCost])

使用此公式,您现在要求通过SUM进行聚合。因此,后一个公式不依赖于行上下文;它只需要一个筛选上下文,它提供了正确的结果。

4.3 使用迭代器创建行上下文

您了解到DAX在您定义计算列时会自动创建行上下文。在这种情况下,引擎会逐行评估DAX表达式。现在,是时候学习如何使用迭代器在DAX表达式中创建行上下文。

您可能还记得第2章“DAX简介”中所说的,所有X结尾函数都是迭代器,也就是说,它们遍历表并为每行计算表达式,最后使用不同的算法聚合结果。例如,查看以下DAX表达式:

[IncreasedSales] := SUMX ( Sales, Sales[SalesAmount] * 1.1 )

SUMX是一个迭代器,它迭代Sales表,并且对于表的每一行,它计算销售额增加10%到它的值,最后返回所有这些值的总和。为了计算每一行的表达式,SUMX在Sales表上创建一个行上下文,并在迭代期间使用它。
DAX在包含当前迭代行的行上下文中计算内部表达式(SUMX的第二个参数)。

值得注意的是,SUMX的不同参数在完整计算流程中使用不同的上下文。让我们仔细看看这个表达式:

= SUMX (
Sales, ←外部上下文
Sales[SalesAmount] * 1.1 ← 外部上下文 + 新行上下文
)

第一个参数Sales使用来自调用者的上下文进行计算(例如,它可能是一个pivot表单元格、另一个度量或查询的一部分),而第二个参数(表达式)则使用外部上下文和新创建的行上下文进行计算。

所有迭代器的行为方式都相同:

  1. 为作为第一个参数接收的表的每一行创建一个新的行上下文。
  2. 评估新创建的行上下文中的第二个参数(以及迭代开始之前存在的任何其他上下文),对于表的每一行。
  3. 聚合在步骤2中计算的值。

重要的是要记住原始上下文在表达式中仍然有效:迭代器只添加一个新的行上下文,他们不会以任何方式修改现有的上下文。此规则通常有效,但有一个重要的例外:如果先前的上下文已经包含了同一表的行上下文,那么新创建的行上下文将隐藏先前存在的行上下文。我们将在下一节中更详细地讨论这个问题。

4.3.1 使用EARLIER函数

在同一个表上有许多嵌套行上下文的情况似乎非常罕见,但实际上,它经常发生。让我们以一个例子来看这个概念。想象一下,您希望为每种产品计算价格较高的其他产品的数量。这将根据价格产生一种产品排名。

为了解决这个问题,我们使用您在前一章中学到的FILTER函数。您可能还记得,FILTER是一个迭代器,它循环遍历表的所有行,并返回一个新表,其中只包含满足第二个参数定义的条件的行。例如,如果要检索价格高于100美元的产品表,可以使用:

= FILTER ( Product, Product[UnitPrice] > 100 )

备注:
细心的读者会注意到FILTER需要是一个迭代器,因为当且仅当Product存在有效的行上下文时,才能计算表达式Product [UnitPrice]> 100;否则Unit Price的实际值将是不确定的。 FILTER是一个迭代器函数,它在第一个参数中为表的每一行创建一个行上下文,这样就可以在第二个参数中计算条件。

现在,让我们回到我们原来的例子:创建一个计算列,它计算价格高于当前价格的产品数量。
如果您要命名当前产品PriceOfCurrentProduct的价格,那么很容易看出这个伪DAX公式可以满足需要:

Product[UnitPriceRank] =
COUNTROWS (
FILTER (
Product,
Product[UnitPrice] > PriceOfCurrentProduct
))

FILTER仅返回价格高于当前价格的产品,COUNTROWS会对这些产品进行计数。唯一剩下的问题是表达当前产品价格的方法,用有效的DAX语法替换PriceOfCurrentProduct。对于“当前”,我们指的是当DAX计算列时当前行中列的值。它比你想象的要难。

您可以在Product表中定义此新计算列。因此,DAX计算行上下文中的表达式。但是,表达式使用FILTER在同一个表上创建新的行上下文。实际上,在上一个表达式的第五行中使用的Product [UnitPrice]是FILTER迭代(我们的内部迭代)的当前行的单价的值。因此,此新行上下文隐藏了由计算列引入的原始行上下文。你看到了这个问题吗?您想要访问单价的当前值,但不使用上次引入的行上下文。相反,您希望使用前面计算列创建的行上下文。

DAX提供了一个使之成为可能的函数:EARLIER。 EARLIER使用前一行上下文而不是新的行上下文来检索列的值。因此,您可以使用EARLIER(Product [UnitPrice])表达PriceOfCurrentProduct的值。

EARLIER是DAX中最奇怪的功能之一。很多用户都对EARLIER感到害怕,因为他们没有考虑行上下文,,也没有考虑到可以通过在同一个表上创建多个迭代来嵌套行上下文。实际上,EARLIER是一个非常简单的功能,可以多次使用。以下代码最终解决了该方案:

Product[UnitPriceRank] =
COUNTROWS (
FILTER (
Product,
Product[UnitPrice] > EARLIER ( Product[UnitPrice] )
))
+ 1

在图4-8中,您可以看到Product表中定义的计算列,该列已使用Unit Price按降序排序

图4-8 UnitPriceRank是EARLIER如何在嵌套行上下文中导航的有用示例。

因为有14种产品单价相同,所以他们的排名总是1;第15个产品的排名为15,与其他同等价格的产品齐名。我们建议您仔细研究并理解这个小示例,因为这是一个非常好的测试,可以检查您使用和理解行上下文的能力,以及如何使用迭代器(在本例中是FILTER)创建它们,以及如何通过EARLIER的使用访问它们之外的值。

备注:
EARLIER接受第二个参数,即跳过的步骤数,以便您可以跳过两个或更多行上下文。此外,还有一个名为EARLIEST的函数,它允许您直接访问为表定义的最外层的行上下文。老实说,EARLIER的第二个参数和EARLIEST都不经常使用:虽然有两个嵌套的行上下文是一个常见的场景,但是有三个或更多的行上下文是很少发生的事情。

在结束这个例子之前,值得注意的是,如果您想将这个值转换为一个更好的排名(即,一个值从1开始,然后增长为1,创建一个序列1,2,3…),那么计算价格而不是计算产品就足够了。在这里,您在上一章中学到的VALUES函数可以帮助您:

Product[UnitPriceRankDense] =
COUNTROWS (
FILTER (
VALUES ( Product[UnitPrice] ),
Product[UnitPrice] > EARLIER ( Product[UnitPrice] )
))
+ 1
图4-9 UnitPriceRankDense显示更好的排名,因为它计算价格,而不是产品。

我们强烈建议您学习并彻底理解EARLIER,因为您会经常使用它。尽管如此,重要的是要注意,可以在许多场景中使用变量来避免EARLIER的使用。此外,仔细使用变量使代码更容易阅读。例如,您可以使用以下表达式计算先前计算的列:

Product[UnitPriceRankDense] =
VAR
CurrentPrice = Product[UnitPrice]
RETURN
COUNTROWS (
FILTER (
VALUES ( Product[UnitPrice] ),
Product[UnitPrice] > CurrentPrice
))
+ 1

在最后一个示例中,使用变量,将当前单位价格存储在CurrentPrice变量中,稍后您将使用该变量执行比较。给变量命名,可以使代码更容易阅读,而无需每次读取表达式时都遍历行上下文堆栈来理解计算流程。

4.4 理解FILTER、ALL和上下文转换

在前面的示例中,我们使用FILTER作为筛选表的一种方便方法。FILTER是一个非常常用的函数,当您想应用一个筛选器来进一步限制现有上下文时,可以使用它。

假设您想创建一个计算红色产品数量的度量值。根据你目前所学的知识,这个公式很简单:

[NumOfRedProducts] :=
COUNTROWS (
FILTER (
Product,
Product[Color] = "Red"
))

这个公式工作正常,您可以在数据透视表中使用它;例如,将品牌放在行上以生成如图4-10所示的报告。

图4-10您可以使用FILTER函数轻松计算红色产品的数量。

在继续这个例子之前,停下片刻并仔细考虑DAX如何计算这些值是很有用的。品牌是Product表的一列。引擎在行中的品牌定义的上下文中计算每个单元格的NumOfRedProducts。因此,每个单元格显示具有由相应行指定的品牌的红色产品的数量。之所以会发生这种情况,是因为当您要求迭代Product表时,实际上是在要求迭代当前筛选上下文中是可见的Product表,其中只包含特定品牌的产品。这可能看起来微不足道,但最好还是记住它而不是忘记它。

如果在包含颜色的工作表上放置切片器,则更明显。在图4-11中,我们创建了两个相同的数据透视表,其中切片器为彩色。您可以看到左侧的颜色为红色,并且数字与图4-10中的相同,而在右侧,数据透视表为空,因为切片器的颜色为绿色。

图4-11 DAX在切片器定义的外部上下文环境中计算NumOfRedProducts。

在右侧数据透视表中,传递给FILTER的Product表仅包含Green产品,并且由于没有产品可以同时为红色和绿色,因此它总是计算为BLANK(即,FILTER不会返回任何行给COUNTROWS)。

这个例子的重要部分是,在同一个公式中,有一个来自外部的筛选上下文(由切片器选择影响的数据透视表单元格)和公式中引入的行上下文。

两个上下文同时工作并修改公式结果。 DAX使用筛选器上下文来计算Product表,并使用行上下文在迭代期间筛选行。

此时,您可能希望定义另一个公式,该公式返回红色产品的数量,而不管在切片器上进行的选择。因此,您希望忽略在切片器上进行的选择,并始终返回红色产品的数量。

您可以使用ALL函数轻松完成此操作。 ALL返回表的内容,忽略筛选上下文,也就是说,它总是返回表的所有行。您可以使用以下表达式定义名为NumOfAllRedProducts的新度量值:

[NumOfAllRedProducts] :=
COUNTROWS (
FILTER (
ALL ( Product ),
Product[Color] = "Red"
))

这一次,我们使用ALL(Product),而不是仅引用Product,这意味着我们要忽略现有的筛选上下文并始终遍历所有产品。结果绝对不是我们所期望的,如图4-12所示。

!图4-12 NumOfAllRedProducts返回奇怪的结果。](https://upload-images.jianshu.io/upload_images/14379683-2af9c62d0f6635e8.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

这里有几个有趣的事情要注意:

  • 结果总是99,无论行上选择的品牌如何。
  • 左透视表中的品牌与右透视表中的品牌不同。

让我们研究这两个主题。首先,99是数据库中红色产品的总数。使用ALL后,我们已从Product表中删除了所有筛选器,也就是说,我们已删除了颜色筛选器和品牌筛选器。这是一个不想要的效果,但不幸的是,在目前对DAX的有限知识下,我们没有任何其他选择。 ALL非常强大,但它是一个全有或全无的函数:如果使用它,它会删除所有筛选器;它没有选择只删除其中的一部分。更具体地说,我们希望仅删除颜色上的筛选器,而不改变所有其他筛选器。在下一章中,您将学习如何通过使用CALCULATE来解决此问题。

第二点更容易理解:因为我们选择了Green,我们看到的是绿色产品的制造商,而不是所有产品的制造商。因此,最右边的数据透视表显示了绿色产品制造商在数据库中的红色产品总数。这是因为用于填充数据透视表行的制造商列表是在原始筛选器上下文中计算的,其中包含颜色等于绿色的筛选器。计算完行后,计算出值,结果总是返回99。

备注:
DAX的这种行为称为“AutoExists逻辑。”它看起来非常自然,因为它隐藏了不存在的值,尽管有一些内部复杂性。在第10章“高级计算上下文”中,我们将专门用一节来详细描述AutoExists行为。

我们现在不想解决这个问题。稍后,当您学习CALCULATE时,您将获得解决方案,它具有特定的功能来解决这类问题。到目前为止,我们已经使用这个例子来说明,在同一个表达式中,由于上下文共存及相互作用,您可能会发现一个相对简单的公式返回奇怪结果。

4.5 使用许多表

我们刚刚开始学习上下文,到目前为止,这让我们得到了一些有趣(和令人惊讶)的结果。您可能已经注意到我们故意只使用一个表:Product。只有一个表,您只需面对同一表达式中行上下文和筛选上下文之间的交互。

很少有数据模型只包含一个表。在您的数据模型中,很可能有许多表通过关系连接。因此,一个有趣的问题是“两种上下文如何在关系中作用?”而且,由于关系有一个方向,我们需要了解在关系的一端和多段发生了什么。最后,为了使事情变得更加困难,请回想一下,关系可以是单向的,也可以是双向的,具体取决于您如何定义关系本身的交叉筛选器方向。

如果在关系的多端的表上创建行上下文,您是否希望它允许您使用一端的列?此外,如果您在关系的一端创建行上下文,您是否希望能够访问多端的表中的列?另外,筛选上下文怎么样?你是否希望在多端表上放置一个筛选器并将其传递到一端的表中?任何答案都可能是正确的,但我们有兴趣学习DAX在这些情况下的行为,即理解DAX语言如何通过关系定义上下文的传递。正如您将要学习的那样,上下文和关系之间存在一些微妙的相互作用,学习它们需要一些耐心。

为了研究这个场景,我们使用了一个包含6个表的数据模型,如图4-13所示。

图4-13在这里,您可以看到用于学习上下文和关系之间交互的数据模型。

关于这个模型有几点需要注意:

  • 从销售到产品类别,通过产品和产品子类别,有一对一对多的关系链。
  • 唯一的双向关系是销售和产品之间的关系。所有剩余的关系都设置为单向交叉滤波方向。

现在我们已经定义了模型,让我们通过查看一些DAX公式来开始查看上下文的行为。

4.5.1 行上下文和关系

行上下文和关系的交互非常容易理解,因为没有什么需要理解的:它们不会以任何方式交互,至少不会自动交互。

想象一下,您想在Sales表中创建一个计算列,其中包含存储在事实表中的单价与Product表中存储的产品清单价格之间的差异。你可以尝试这个公式:

Sales[UnitPriceVariance] = Sales[UnitPrice] - Product[UnitPrice]

此表达式使用来自两个不同表的两列,DAX仅在Sales上迭代的行上下文中计算,因为您在该表(Sales)中定义了计算列。Product在与Sales关系的一端,因此您可能希望能够访问相关行(售出产品)的单价。不幸的是,这不会发生。 Sales中的行上下文不会自动传递到Product,如果您尝试使用上面的公式创建计算列,则DAX会返回错误。

如果要从关系多端的表中访问关系一端的列(如本示例中的情况),则必须使用RELATED函数。 RELATED接受列名作为参数,并从当前行上下文开始,通过遵循多对一方向中的一个或多个关系找到对应行中的列的值。

您可以使用以下代码更正上面的公式:

Sales[UnitPriceVariance] = Sales[UnitPrice] - RELATED (Product[UnitPrice] )

当您在关系的多端的表上有行上下文时,RELATED有效。如果行上下文处于关系的一端,则您无法使用它,因为在通过关系可能会检测到许多行。在这种情况下,您需要使用RELATEDTABLE,它是RELATED的伴侣。您可以在关系的一端使用RELATEDTABLE,它将返回与当前行关联的所有行(在多端的表中)。例如,如果要计算每个产品的销售数量,可以使用以下公式,在Product上定义一个计算列:

Product[NumberOfSales] = COUNTROWS ( RELATEDTABLE ( Sales ) )

此表达式计算Sales表中与当前产品对应的行数。您可以在图4-14中看到结果。

图4-14当关系的一端有行上下文时,RELATEDTABLE非常有用。

值得注意的是,RELATED和RELATEDTABLE都可以遍历一长串关系来收集他们的结果;它们不限于单个关系。例如,您可以使用与之前相同的代码创建列,但这次是在Product Category表中:

'Product Category'[NumberOfSales] = COUNTROWS ( RELATEDTABLE (
Sales ) )

结果是该类别的销售数量,该类别遍历从Product Category到Product Category,然后到Product,最终到达Sales表的关系链。

备注:
RELATED和RELATEDTABLE的一般规则的唯一例外是一对一的关系。如果两个表共享1:1的关系,那么您可以在两个表中同时使用RELATED和RELATEDTABLE,结果将得到列值或具有单行的表,这取决于您使用的函数。

关于关系链的唯一限制是所有关系需要是相同类型(即,一对多或多对一),并且所有关系都朝着相同的方向。如果你有两个表通过一对多和多对一连接,中间有一个桥表,那么RELATED和RELATEDTABLE都不会起作用。 1:1关系同时表现为一对多和多对一。因此,您可以在一对多的关系链中建立1:1的关系,而不会中断。

让我们用一个例子来说明这个概念。您可能认为客户与产品相关,因为客户与销售之间存在一对多关系,而销售与产品之间存在多对一关系。因此,一个关系链将两个表连接起来。然而,这两种关系并不在同一个方向上。

我们将此场景称为多对多关系。换句话说,客户与许多产品(购买的产品)有关,而产品与许多客户(购买产品的客户)有关。稍后您将学习如何使多对多关系工作的细节;现在让我们关注行上下文。如果您尝试在多对多关系中应用RELATEDTABLE,结果可能不是您所期望的。例如,考虑在Product中创建计算列的一个公式:

Product[NumOfBuyingCustomers] = COUNTROWS ( RELATEDTABLE (
Customer ) )

对于每一行,您可能希望看到购买该产品的客户数量。出乎意料的是,结果将始终是18869,即数据库中的客户总数,如图4-15所示。

图4-15 如果您尝试遍历多对多关系,则RELATEDTABLE不起作用。

RELATEDTABLE不能遵循关系链,因为它们的方向不同:一个是一对多,另一个是多对一。因此,Product的筛选器无法到达客户。值得注意的是,如果您在相反的方向尝试这个公式,也就是说,您计算每个客户购买的产品数量,结果将是正确的:代表客户购买的产品数量的每一行都有不同的数字。这种行为的原因不是筛选上下文的传递,而是由RELATEDTABLE中隐藏的计算创建的上下文转换。为了完整起见,我们添加了最后一个注释。现在还不是详细说明这一点的时候:在阅读第5章“理解CALCULATE和CALCULATETABLE”之后,您将更好地理解这一点。

4.5.2 筛选上下文和关系

您已经了解到行上下文不与关系交互,并且如果要遍历关系,则可以使用两种不同的函数,具体取决于访问目标表时您所处的关系的哪一端。

筛选上下文的行为方式不同:它们以自动方式与关系交互,并且根据您设置关系筛选的方式,它们具有不同的行为。一般规则是,如果在关系本身上设置的筛选方向使传递可行,则筛选上下文通过关系传递。

通过使用带有几个度量值的简单数据透视表,可以很容易地理解这种行为。在图4-16中,您可以看到一个数据透视表,浏览我们目前使用的数据模型,其中定义了三个非常简单的度量值,如下所示

[NumOfSales] := COUNTROWS ( Sales )
[NumOfProducts] := COUNTROWS ( Product )
[NumOfCustomers] := COUNTROWS ( Customer )
图4-16在这里,您可以看到筛选上下文和关系的行为。

筛选器在产品颜色上。Product是与Sales之间一对多关系的一端,因此筛选下文从Product传播到Sales,您可以看到这一点,因为NumOfSales度量值仅计算具有特定颜色的产品的销售额。 NumOfProducts显示每种颜色的产品数量,每行(颜色)的不同值是您所期望的,因为筛选器位于我们计算的同一个表中。

另一方面,计算客户数量的NumOfCustomers总是显示相同的值,即客户总数。这是因为Customer和Sales之间的关系,如图4-17所示,在Sales方向上有一个箭头。

图4-17客户与销售之间的关系是单向关系。

筛选器从Product开始,然后传递到Sales(按照从Product到Sales的箭头,已启用)但是,当它尝试传递到Customer时,它没有找到让它继续的箭头。因此,它停了下来。单向关系允许在单个方向上传递筛选上下文,而不是两者。

你可以认为关系上的箭头就像信号灯。如果它们被启用,则信号灯亮绿色并且传递发生。另一方面,如果未启用箭头,则信号灯亮红灯并且不能传递筛选器。

始终从任何关系的一端到多端启用箭头。您可以选择从多端到一端启用它。如果让箭头禁用,那么传递将不会从多端到一端。

如果查看图4-18中所示的数据透视表,可以更好地理解该行为。这次我们不是在行上使用产品颜色,而是按客户教育进行分类。

图4-18按客户培训过滤,Product表也被过滤。

这次筛选器从Customer开始。它可以到达Sales表,因为在对应关系中启用了箭头。然后,从Sales,它可以进一步传递到Product,因为Sales和Product之间的关系是双向的。

现在,您向模型添加一个类似的度量值,计算子类别的数量,例如以下一个:

NumOfSubcategories := COUNTROWS ( 'Product Subcategory' )

将其添加到报告中,您将看到客户教育未过滤子类别的数量,如图4-19所示。

图4-19如果关系是单向的,则客户无法过滤子类别。

这是因为Product和Product Subcategory之间的关系是单向的,即它允许筛选器在单个方向上传播。一旦启用从Product开始的箭头并转到Product子类别,您将看到筛选器传递,如图420所示。


图4-20如果关系是双向的,客户也可以过滤子类别。

正如行上下文所发生的那样,要遍历表格需要遍历多少步骤并不重要:只要有一个启用了的关系链,就会发生自动传播。例如,如果在Product Category上放置筛选器,则筛选器将传递到Product Subcategory,然后传递到Product,最后传递到Sales。

重要的是要注意,在筛选上下文中没有函数可以根据关系链从表中获取列或值,因为筛选上下文在DAX表达式中的传递是自动发生的,而行上下文的传递则不是,并且需要使用RELATED和RELATEDTABLE指定传递。

4.5.3 VALUES函数介绍

前面的示例非常有趣,因为它显示了如何使用筛选方向计算购买产品的客户数量。然而,如果您只对计算客户数量感兴趣,那么还有一个有趣的替代方法,我们将借此机会介绍另一个强大的函数:VALUES。

VALUES是一个表函数,它只返回一列的表,包含当前筛选上下文中可见的列的所有值。VALUES有许多高级用途,稍后我们将介绍。到目前为止,开始使用VALUES只是为了更好地熟悉它的行为。

在上一个数据透视表中,您可以使用以下DAX表达式修改NumOfCustomers的定义:

[NumOfCustomers] := COUNTROWS ( VALUES ( Sales[CustomerKey] )
)

此表达式不计算Customer表中的客户数。相反,它计算Sales中CustomerKey列在当前筛选上下文中可见的值的数量。因此,表达式不依赖于Sales和Customers之间的关系,它只使用Sales表。

当您在Products上放置筛选器时,它也总是过滤Sales,因为筛选器从Product传播到Sales。因此,并非所有CustomerKey的值都可见,而是仅显示与筛选产品的销售相对应的行中的值。

表达式的含义是“计算与所选产品相关的销售的客户密钥数量。”因为客户密钥代表客户,所以表达式有效地计算购买这些产品的客户数量。

备注:
您可以使用DISTINCTCOUNT获得相同的结果,DISTINCTCOUNT计算列的不同值的数量。作为一般规则,使用DISTINCTCOUNT比使用VALUES的COUNTROWS更好。在这里,我们使用COUNTROWS和VALUES是为了教育目的,因为VALUES是一个有用的函数,即使它最常见的用法将在后面的章节中详细介绍。

使用VALUES而不是利用关系的方向既有优点也有缺点。当然,在模型中设置筛选要灵活得多,因为它使用了关系。因此,您不仅可以使用CustomerKey计算客户,还可以计算客户的任何其他属性(例如,客户类别的数量)。话虽如此,可能有一些原因迫使您使用单向筛选,或者出于性能原因可能需要使用VALUES。我们将在第12章“高级关系处理”中更详细地讨论这些主题。

4.5.4 介绍ISFILTERED、ISCROSSFILTERED函数

有两个函数非常有用,可以帮助您更好地理解筛选上下文的传递。此外,学习它们是介绍透视表计算中最有趣的概念之一的好方法,即检测要从DAX中计算值的单元格。

这两个函数旨在让您检测列的所有值是否在当前筛选上下文中可见,它们是:

  • ISFILTERED:返回TRUE或FALSE,这取决于作为参数传递的列上是否具有直接过滤器,也就是说,它已放在行、列、切片器或筛选器上,并且正在对当前单元格进行筛选。
  • ISCROSSFILTERED返回TRUE或FALSE,具体取决于列是否具有筛选器,因为另一个筛选器的自动传递,不仅因为它上面有直接是筛选器。

在本节中,我们感兴趣的是使用这些函数来理解筛选上下文的传递。因此,我们将创建虚拟表达式,它们仅用作学习工具。

如果使用此定义创建新度量值:

[CategoryFilter] := ISFILTERED ( 'Product Category'[Category]
)

这个简单的度量返回应用于产品类别名称的ISFILTERED函数的值。然后,您可以创建第二个度量值,该度量对产品颜色进行相同的测试。代码是

[ColorFilter] := ISFILTERED ( Product[ColorName] )

如果将两个度量值添加到数据透视表,将类别放在切片器中,将颜色放在行上,结果将类似于图4-21。

图4-21您可以看到类别从未被过滤,并且颜色在任何地方都被过滤,但总计过滤。

有趣的是,类别从未被筛选,因为即使我们添加了切片器,我们也没有对其进行选择。另一方面,颜色总是在行上进行筛选,因为每行都有特定的颜色,但总计没有,因为筛选上下文中不包含任何产品选择。

备注:
总计的这种行为,也就是说,没有行和列的筛选器,当你想修改公式以便于在总计级别上显示一个不同值时,这非常有用。实际上,您将检查是否对数据透视表报告中出现的属性进行了筛选,以便了解所计算的单元格是位于数据透视表的内部,还是位于总计级别。

如果现在从类别切片器中选择一些值,则结果会更改。现在该类别总是有一个筛选器,如图4-22所示。实际上,切片器引入的筛选上下文即使在数据透视表的总计级别也是有效的。


图4-22切片器引入的过滤器也在总计级别上工作。

ISFILTERED可用于检测列上是否有直接筛选器。在某些情况下,列不会显示其所有值,不是因为您正在筛选列,而是因为您在另一列上放置了筛选器。例如,如果您筛选颜色并查询产品品牌的值,那么您将仅得到该特定颜色的产品品牌。当一个列因为另一个列上的筛选器而被筛选时,我们说该列是交叉筛选的,并且ISCROSSFILTERED函数可以检测到这一情况。

如果将这两个新度量值添加到要检查的数据模型中,这次是ISCROSSFILTERED的颜色和类别:

[CrossCategory] := ISCROSSFILTERED ( 'Product
Category'[Category] )
[CrossColor] := ISCROSSFILTERED ( Product[Color] )

然后您将看到如图4-23所示的结果。


图4-23使用ISCROSSFILTERED函数可以看到交叉过滤。

您可以看到颜色是交叉筛选而类别不是。此时,一个有趣的问题是“为什么类别未被筛选?”当您过滤颜色时,您可能希望只看到特定颜色的产品类别。要回答这个问题,您需要记住该类别不是Product表的列。相反,它是产品类别的一部分,关系上的箭头不会让关系传递。如果更改数据模型,启用从产品到产品类别的完整关系链的双向筛选,则结果将不同,如图4-24所示。

图4-24启用双向过滤显示现在即使未直接过滤,也会对类别进行交叉过滤。

在本节中,您已经看到了ISFILTERED和ISCROSSFILTERED的一些示例,主要用于教育目的,因为您仅使用它们来更好地理解筛选上下文如何通过关系传递。在接下来的章节中,通过编写高级DAX代码,您将了解为什么这两个函数如此有用。

4.6 计算上下文回顾

让我们回顾一下我们在计算上下文中学到的所有知识。

  • 计算上下文是通过筛选数据模型并在需要访问列值时提供当前行概念来修改DAX表达式的值的上下文。
  • 评估上下文由两部分组成:行上下文和过滤器上下文。它们共存,它们存在于所有公式中。为了理解公式的行为,您始终需要考虑这两种上下文,因为它们同时起作用。
  • 定义计算列时,DAX会自动创建行上下文。您还可以使用迭代器函数以编程方式创建行上下文。所有迭代器都定义行上下文。
  • 您可以嵌套行上下文,在这种情况下,EARLIER函数可用于访问上一行上下文。
  • 在使用数据透视表时,您可以通过使用行、列、切片器和筛选器上的字段来创建筛选上下文。有一种方法可以使用CALCULATE以编程方式创建筛选上下文,但我们还没有学到它。我们希望在这一点上你应该非常好奇地了解它!
  • 行上下文不会自动通过关系传递。通过使用RELATED和RELATEDTABLE手动进行传递。您需要在一对多关系的正确使用这些函数:在多端使用RELATED,在一端使用RELATEDTABLE。
  • 筛选上下文随筛选关系自动传递。它总是从关系的一端传递到多端。此外,您还可以选择启用从多端到一端的传递。没有函数可用来强制传递:根据数据模型中关系的定义,引擎内的所有事情都是自动发生的。
    +VALUES返回一个包含单列表的表,该表包含当前筛选上下文中可见的列的所有唯一值。您可以使用结果表作为任何迭代器的参数。

至此,您已经学习了DAX语言中最复杂的概念主题。这些点控制了公式的所有计算流程,它们是DAX语言的支柱。当你遇到一个表达式,它不按你想象的计算,很有可能是因为你还没有完全理解这些规则。

正如我们在引言中所说,在第一次阅读时,所有这些主题看起来都非常简单。事实上,他们是。使它们复杂的原因在于,在一个复杂表达式中,公式的不同部分可能有多个活动的计算上下文。掌握计算上下文是您将通过经验获得的技能,在下一章中,我们将通过展示许多示例来帮助您。通过使用一些DAX公式,您将直观地知道使用了哪些上下文以及它们需要哪些函数,并最终掌握DAX语言。

4.6 创建参数表

在本章中,您学习了许多关于计算上下文的理论概念。现在是时候使用它们中的一些知识来解决一个有趣的场景并学习一种非常有用的技术,即使用参数表。

参数表的想法是创建一个与数据模型其余部分无关的表,但是您将在DAX表达式内部使用它来修改公式的行为。一个例子可能有助于说明这一点。想象一下,您创建了一个显示销售额总和的报告,并且由于您的公司销售的商品很多,因此报告中显示的数字非常大。因为我们的示例数据库没有遇到此问题,所以我们创建了一个度量值,它对SalesAmount的立方求和,而不是使用SalesAmount列,因此数字更大,所描述的场景更加真实。在图4-25中,您可以看到此报告。

图4-25读取大数字报告有时很困难。

这份报告的问题是数字很大,而且往往难以阅读。它们是数百万、数十亿还是数万亿?而且,他们在报告中占用了大量空间,没有携带太多信息。对于这种报告,一个常见的要求是使用不同的比例显示数字。例如,您可能希望显示除​​以千或百万的值,以便它们得到更小的数字,同时仍然携带有用的信息。

您可以通过修改度量值并将其除以千来轻松解决此问题。唯一的问题是,根据数字的相对大小,您可能希望将它们视为真实值(如果它们足够小),除以数千或除以数百万。此时创建三个度量值似乎很麻烦,我们希望找到一个更好的解决方案,无需创建多个不同的度量值。

我们的想法是让用户在使用切片器时决定在报告中应用哪个比例。在图4-26中,您可以看到我们要构建的报告示例。

图4-26切片器不会在此处过滤值。它用于更改数字的显示方式。

报告的有趣之处在于,您不使用ShowValueAs切片器来筛选数据。相反,您将使用它来更改数字所使用的比例。当用户选择Real Value时,将显示实际数字。如果选择了千位,则实际数字除以一千,并以相同的度量值显示,而无需数据透视表的布局。这同样适用于数百万和数十亿。

要创建此报告,首先需要一个包含要在切片器上显示的值的表。在我们的示例中,我们使用Excel表来存储比尺。在更专业的解决方案中,最好将表存储在SQL数据库中。在图4-27中,您可以看到此表的内容。

图4-27此Excel表将是报表中切片器的源。

显然,您无法与该表创建任何关系,因为Sales不包含任何可以用于与该表关联的列。不过,只要表在数据模型中,您就可以使用ShowValueAs列作为切片器的源。是的,你最终得到一个什么都不做的切片器,但是一些DAX代码可以神奇地执行阅读用户选择并进一步修改报告内容。

您需要用于度量值的DAX表达式如下:

[ScaledSalesAmount] :=
IF (
HASONEVALUE ( Scale[DivideBy] ),
DIVIDE ( [Sales Amount], VALUES ( Scale[DivideBy] ) ),
[Sales Amount]
)

这个公式中有两个值得注意的事项:

  • IF函数测试的条件是:HASONEVALUE(Scale [ShowValueAs])。此模式非常常见:判断Scale表的列是否只有一个值可见。如果用户没有在切片器中选择任何内容,则列的所有值在当前筛选上下文中都是可见的;也就是说,HASONEVALUE将返回FALSE(因为该列中有许多不同的值)。然后,如果用户选择了单个值,则只有那个值可见,HASONEVALUE将返回TRUE。因此,条件读作:“如果用户为ShowValueAs属性选择了单个值。”
  • 如果选择了单个值,那么您就知道单个行是可见的。因此,您可以计算VALUES(Scale [DivideBy]),并确保结果表只包含一列和一行(筛选下文中可见的一行)。 DAX将VALUES返回的一行一列表转换为标量值。如果在结果是包含多行的表时尝试使用VALUES读取单个值,则返回一个错误。但是,在此特定方案中,您确定返回的值只有一个,因为IF函数测试了前面的条件。

因此,您可以将表达式读作:“如果用户在切片器中选择了单个值,则显示销售额除以相应的分母,否则显示原始销售额。”结果是一个可以交互式更改显示值的报告,使用切片器就好像它是一个按钮。显然,由于报告仅使用标准DAX公式,因此当部署到SharePoint或Power BI时也可以使用。

参数表在构建报表时非常有用。我们展示了一个非常简单(但十分常用)的例子,但唯一的限制是你的想象力。您可以创建参数表来修改数字的计算方式,更改特定算法的参数,或执行其他复杂操作以更改DAX代码返回的值。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容