Pure aggregate Cypher builder + decoder for AshArcadic. Builds ONE parameterized
statement per Ash.Query.Aggregate (each carries its own filter/field/uniq?/
include_nil?/default_value); RETURN uses SYNTHETIC aliases (agg<i>), never the
caller name (Rule 1). Value-reading aggregates over non-summable/non-orderable/
sensitive (:binary) storage fail closed value-free — a correctness guard mirroring
{:sort, :binary} AND a leak guard (a min/list over an encrypted-binary attr
would order-by / return ciphertext into the result; §6.4). A list/first requesting
include_nil?: true also fails closed value-free (ArcadeDB collect drops nulls; §6.5).
Empty sets — and value-reading sets with no non-null field values — decode to the
aggregate struct's own .default_value (spec §6.3).
Summary
Functions
Builds {:ok, cypher, params} for ONE aggregate over the base %AshArcadic.Query{},
or a value-free {:error, reason} from the field guard (§6.4). The WHERE ANDs the
base query's filters with the aggregate's OWN query.filter (C2 — a shared RETURN
can't express distinct per-agg filters, so each aggregate is its own statement). The
RETURN uses synthetic alias agg0; value-reading kinds carry a count(n.<field>) AS agg0_card companion (§6.1). :first prepends WITH n ORDER BY <agg sort>.
Decodes the single result row for one aggregate to its Ash value (§6.3). When the
cardinality companion reports agg0_card == 0 (or an inherently empty result —
count → 0, list → []), returns the aggregate struct's .default_value (which Ash
pre-populates as caller_default || default_value(kind)) — NOT ArcadeDB's sum → 0
(probe G7). exists coerces to a boolean; value-returning kinds (min/max/first
and list elements) coerce through Cast.load_value/2 by the field's storage type.
Guards an aggregate's field against its kind's storage requirement (§6.4). Returns
:ok or a value-free {:error, reason} (names field + kind only, never the value).
count/exists are always allowed (read only presence). A non-atom field
(expression/calculation aggregate) is not Cypher-expressible → {:error, :expression_field} (never to_string-ed — that would raise carrying the struct).
The RETURN expression for one aggregate, aliased to alias (a synthetic agg<i>).
Value-reading kinds return {expr, :companion} — a count(n.<field>) AS <alias>_card
companion (inlined here for sum/avg/min/max, appended by build_statement/3 for :first).
The companion counts NON-NULL field values, so decode maps both an empty set AND an
all-null-field set (card == 0) to the struct default — matching Ash/SQL aggregate
semantics, which skip nulls (ArcadeDB sum over empty/all-null = 0 ≠ Ash nil, probe G7).
count/exists/list return {expr, :plain} (correct as returned; list empty → []).
<f> is Identifier.validate!-checked. :first ordering is emitted by build_statement/3
(a WITH n ORDER BY … prefix), not here.
Functions
@spec build_statement(AshArcadic.Query.t(), Ash.Query.Aggregate.t(), %{ required(atom()) => {Ash.Type.t(), keyword()} }) :: {:ok, String.t(), map()} | {:error, term()}
Builds {:ok, cypher, params} for ONE aggregate over the base %AshArcadic.Query{},
or a value-free {:error, reason} from the field guard (§6.4). The WHERE ANDs the
base query's filters with the aggregate's OWN query.filter (C2 — a shared RETURN
can't express distinct per-agg filters, so each aggregate is its own statement). The
RETURN uses synthetic alias agg0; value-reading kinds carry a count(n.<field>) AS agg0_card companion (§6.1). :first prepends WITH n ORDER BY <agg sort>.
A DISTINCT base query interposes the read render's collect-group pipeline (plus outer
sort/paging when present) before the fold, and moves the aggregate's own filter to a
post-dedup WITH n WHERE stage — the aggregate folds exactly the representatives the
read would return (ETS-reference parity; a pre-dedup fold returns wrong counts/sums).
@spec decode([map()], Ash.Query.Aggregate.t(), %{ required(atom()) => {Ash.Type.t(), keyword()} }) :: term()
Decodes the single result row for one aggregate to its Ash value (§6.3). When the
cardinality companion reports agg0_card == 0 (or an inherently empty result —
count → 0, list → []), returns the aggregate struct's .default_value (which Ash
pre-populates as caller_default || default_value(kind)) — NOT ArcadeDB's sum → 0
(probe G7). exists coerces to a boolean; value-returning kinds (min/max/first
and list elements) coerce through Cast.load_value/2 by the field's storage type.
@spec guard_field(Ash.Query.Aggregate.t(), %{ required(atom()) => {Ash.Type.t(), keyword()} }) :: :ok | {:error, term()}
Guards an aggregate's field against its kind's storage requirement (§6.4). Returns
:ok or a value-free {:error, reason} (names field + kind only, never the value).
count/exists are always allowed (read only presence). A non-atom field
(expression/calculation aggregate) is not Cypher-expressible → {:error, :expression_field} (never to_string-ed — that would raise carrying the struct).
@spec return_expr(Ash.Query.Aggregate.t(), String.t()) :: {String.t(), :plain | :companion}
The RETURN expression for one aggregate, aliased to alias (a synthetic agg<i>).
Value-reading kinds return {expr, :companion} — a count(n.<field>) AS <alias>_card
companion (inlined here for sum/avg/min/max, appended by build_statement/3 for :first).
The companion counts NON-NULL field values, so decode maps both an empty set AND an
all-null-field set (card == 0) to the struct default — matching Ash/SQL aggregate
semantics, which skip nulls (ArcadeDB sum over empty/all-null = 0 ≠ Ash nil, probe G7).
count/exists/list return {expr, :plain} (correct as returned; list empty → []).
<f> is Identifier.validate!-checked. :first ordering is emitted by build_statement/3
(a WITH n ORDER BY … prefix), not here.
Assumes guard_field/2 has already passed for this aggregate — field MUST be an
atom. A non-atom field would raise Protocol.UndefinedError in ident/1
(to_string/1), carrying the struct (the exact Rule-4 leak guard_field/2
prevents); build_statement/3 gates on guard_field/2 first.