Skip to content

ADR 0048: Fluent Cypher aggregates and bounded collect

Date: 2026-10-16. Status: Accepted with the verification below. Extends the WITH aggregation decision in ADR 0041, which introduced counts only.

Context

Fluent Cypher could introduce count(...) in WITH, but not the other common aggregates. Questions such as "the earliest and latest release per actor" or "the titles of an actor's movies" had to fall back to native Cypher. Two hazards shape the design. First, aggregates have native null rules that differ from LINQ: min, max and avg of an all-null group are null, sum is 0 and collect skips nulls. Second, collect returns a list whose size is unbounded, although Fluent bounds every result with LIMIT.

Decision

Add Graph.Min, Graph.Max, Graph.Sum, Graph.Avg, Graph.Collect and Graph.CollectDistinct. Each returns a CypherValue<T> introduced by With(...), like Graph.Count:

API Cypher Value type Null rule
Min(expr), Max(expr) min(x), max(x) The property's type Null for an all-null group, for example after OPTIONAL MATCH; project (int?)value.Value
Sum(expr) sum(x) long for int/long properties, double for double Never null: an all-null group sums to 0
Avg(expr) avg(x) double? Null for an all-null group
Collect(expr, max), CollectDistinct(expr, max) collect(x)[..$max] IReadOnlyList<T> Nulls skipped; empty for an all-null group
  • Semantics are native. These are explicit native aggregates, not C# lambdas. In later lambda predicates, ADR 0047 treats sum as never null and min, max and avg as possibly null, so != on them is rejected (GBFC110).
  • collect must be bounded. The maximum (1–10,000) is required and is applied on the server by list slicing, so a single row cannot carry an unbounded list. Element order is not guaranteed.
  • Readers use the aggregated property's binding. min, max and collect results are read like the property itself, so Neo4j's 64-bit integers return as int where the property is int, and collect over a converted property (such as a Guid key stored as a string) returns model values. This makes Collect(key, n) a bounded way to return keys for the keys-to-EF bridge.
  • Aggregates can be returned without WITH. A CypherValue aggregate used only in Return renders inline, and native Cypher groups by the returned non-aggregate columns. The result equals the WITH form; WITH remains necessary to filter on an aggregate. Counts follow the same rule.
  • Converted properties are refused for min, max, sum and avg (GBFC406): the server would aggregate the stored form, which a converter does not order or add like the model value. The operand must be a mapped property expression (GBFC406).

Not adopted

Aggregates over arbitrary expressions, percentileCont/stDev, and WITH grouping keys other than forwarded symbols (a RETURN groups by whatever non-aggregate columns it returns). They remain native Cypher until application evidence asks for them.

Verification

  • Unit: rendering of every aggregate in WITH with a lambda Where over the values; GBFC110 for != on min; GBFC406 for a converted operand; collect bounds; collect over a converted property.
  • Live (ProviderFluentLambdaTests.Aggregates_read_typed_values_and_native_null_groups): per-person min, max, sum, avg and collect after OPTIONAL MATCH equal a C# oracle over the seed rows, including people without credits (null, 0 and an empty list), with Guid keys read back through their converter, and the same aggregates returned without WITH equal the WITH form row for row.