知识库

理解零行上的聚合

Cypher 中的聚合在某些情况下可能比较棘手。特别是当在没有匹配项的 MATCH 语句之后,或者在筛选掉所有结果的筛选操作之后执行聚合时。在某些情况下,在这种情况下使用聚合的查询可能不会产生任何结果,这可能会让一些用户感到惊讶。

当所有先前结果都被过滤掉时,聚合可以成功地在零行上执行,但为了正确执行这些聚合并获得正确的结果,需要理解此行为的某些方面和使用限制。

行、操作符和聚合的回顾

Cypher 中的操作符会产生行,并且它们也会按行执行。当没有更多行时(由于 MATCH 未找到匹配项,或 WHERE 子句过滤),则没有留下可执行的操作,剩余的操作变为无操作。

一个重要的例外是聚合函数,最常见的是 count()collect()

允许使用这些函数,因为存在我们可能需要统计出现次数为 0 的情况,因此 `count()` 为 0 是有意义的

MATCH (person:Person)
WHERE person.nonExistentProperty = 123
// after the WHERE, there are 0 rows
RETURN count(person) as count
// but when we apply the count(), we get a single row with 0

同样,当没有路径时,我们可能希望从匹配的路径中 `collect()` 一些东西,因此空列表也是有意义的

MATCH (person:Person)
WHERE person.nonExistentProperty = 123
// after the WHERE, there are 0 rows
RETURN collect(person) as people
// but when we apply the collect(), we get a single row with []

这些用法都以 0 行作为输入,并产生一个包含计数 0 或空列表的单行输出。现在我们有了可操作的行,查询中的后续操作可以执行(如果我们使用 WITH 而不是 RETURN)。

其他聚合函数在 0 行上运行时可以产生类似的结果。

sum() 产生 0,stDev()stDevP() 产生 0.0,而 avg()min()max()percentileDisc()percentileCont() 产生 null

当存在非聚合项时,您无法在 0 行上进行聚合

聚合仅在执行聚合时存在的非聚合项的上下文中才有意义。

当只存在聚合时,聚合的上下文是针对所有行的。

当存在任何其他非聚合项时,聚合是针对这些项的组合进行的,也称为分组键。

因此,例如,在以下查询片段中,`count()` 聚合是针对某个人的,它统计了每个人参演的电影数量

MATCH (person:Person)-[:ACTED_IN]->(movie:Movie)
WITH person, count(movie) as actedMovieCount
...

然而,这确实需要至少一行才能使聚合工作(我们是针对某个东西进行聚合)。

但在行数降至 0 的情况下,没有分组键(至少没有对应的数据),因此 Cypher 不允许进行聚合,行数保持为 0。

让我们考虑一个使用 `:Movie` 和 `:Person` 节点的情况。`:Movie` 节点有标题,但 `:Person` 节点没有。

我们先来看这个查询

MATCH (person:Person)
WHERE EXISTS(person.title)
WITH count(person) as personCount
MATCH (movie:Movie)
WHERE EXISTS(movie.title)
RETURN personCount, count(movie) as movieCount

这会返回 `personCount` 为 0,`movieCount` 为 38。

以下是事件序列

  1. 匹配到了 `:Person` 节点,但它们都没有 `title` 属性。行数变为 0。

  2. 接下来我们 `count()` `person` 节点,由于只有一个聚合项,这会生成一个包含 `personCount` 为 0 的单行,这是正确的。

  3. 由于我们有可操作的行,可以进行下一个 MATCH,并且由于所有电影都有 `title` 属性,我们得到的是所有电影的计数,参照之前的 `personCount` 条目。我们得到了预期的结果。

但是如果我们改变这个查询的顺序呢?会发生什么?

MATCH (movie:Movie)
WHERE EXISTS(movie.title)
WITH count(movie) as movieCount
MATCH (person:Person)
WHERE EXISTS(person.title)
WITH movieCount, count(person) as personCount
RETURN personCount, movieCount

我们交换了顺序,因此我们首先匹配并聚合电影,然后根据之前的 `movieCount` 匹配并聚合人物。

我们的结果是

(无更改,无记录)

那么这里发生了什么?

  1. 匹配到了 `:Movie` 节点,并且它们都有 `title` 属性。行数变为 38,每部电影一行。

  2. 接下来我们 `count()` `movie` 节点,这会给我们一个包含 `movieCount` 为 38 的单行。

  3. 我们的下一个 MATCH 是匹配具有 `title` 属性的 `:Person` 节点。没有这样的节点存在,所以我们的单行被过滤掉了。我们没有行了!我们的 `movieCount` 数据也消失了(它存储在那个单行中),所以我们无法引用它!

  4. 我们尝试聚合,获取人物的 `count()`,但我们有一个分组键 `movieCount`。我们之前可能有的任何数据都已消失,在行被过滤掉时丢失了。我们无法执行此聚合,因为我们没有任何东西可用作分组键(请注意,没有分组键与分组键的值为 `null` 是不一样的)。我们无法运行聚合。我们不输出任何行。

通过使用 OPTIONAL MATCH 或模式推导式避免问题

当存在分组键但没有行时,聚合就会出现问题,因此我们可以通过避免行数变为 0 来避免这个问题。

一种方法是,当我们知道可能没有匹配项,但仍想继续查询时,使用 OPTIONAL MATCH。这不会过滤掉行,并且我们的聚合将发出预期的结果。

如果我们要从扩展中收集结果,那么可以使用模式推导式作为快捷方式,因为如果推导式中的模式不存在,我们将得到一个空集合,同样不会过滤掉行。

为什么在有 0 行时不能将分组键默认为 null?

对于某些人来说,当存在分组键但有 0 行时无法进行聚合的想法不对劲。一个常见的建议是:为什么不允许聚合(就像没有分组键一样)并将分组键的值设置为 null 呢?

简短的回答是,在这些情况下将作用域内的数据更改为 null 可能会导致意想不到的、严重错误的结果,尤其是在允许查询继续在此错误数据上执行时。最终的查询结果可能不合理。

例如,考虑我们使用前面查询的一个变体,将一些计数存储在节点中以便以后快速访问。我们将修正使用的属性(`person.name` 而不是 `person.title`),但在我们向图中添加 `:Person` 节点之前运行此查询,此时我们只有电影节点。

MATCH (movie:Movie)
WHERE EXISTS(movie.title)
WITH count(movie) as movieCount
MATCH (person:Person)
WHERE EXISTS(person.name)
WITH movieCount, count(person) as personCount
MERGE (count:CountTracker)
SET count.personCount = personCount, count.movieCount = movieCount
RETURN personCount, movieCount

现在我们从前面讨论的内容中了解到,当我们匹配 `:Person` 节点时,行数将变为 0,因为图中还没有任何 `:Person` 节点。结果是,我们 `count(person)` 的聚合将失败,我们将得到 0 行,并且查询中不会有任何进一步的操作可以执行(因为没有可执行的行)。

但是,如果 Cypher 将分组键设为 null 并允许查询继续呢?那么 `movieCount` 将变为 null,`personCount` 将变为 0。`personCount` 之前拥有的任何值(假设它之前有任何值)都将被移除,因为将属性设置为 null 等同于移除该属性。

如果这是一个更复杂的查询,请考虑一个你明知道不可能为 null 的值突然变为 null 的影响。使用该属性可能会导致完全意想不到的结果。如果你将属性设置为现在为 null 的值,你最终可能会擦除属性。你可能依赖于对该值的比较,现在因为它是一个 null,比较结果将是 null(在 Cypher 中,与 null 的不等式结果为 null),并且可能会根据你使用该结果值的目的而进一步传播。

幸运的是,当你比较属性与 `null` 时,在 MATCH 或 WHERE 中不会遇到麻烦,因为我们要求使用 `IS NULL` 或 `IS NOT NULL` 进行此检查,使用常规的属性值与 null 的相等性比较将始终失败。

然而,应该清楚的是,将分组键设置为 null 可能会带来负面和意想不到的后果,特别是当这些值用于写入图时。如果我们不返回并检查输出,就有可能将错误数据写入图,而且谁知道何时才能检测到。

由于这些原因,我们认为在这些情况下保持 0 行比突然意外地改变变量值并让查询在不健全的状态下继续执行更为正确。