AshArcadic.Aggregate (AshArcadic v0.1.0)

Copy Markdown View Source

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

build_statement(base, agg, types)

@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).

decode(list, agg, types)

@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.

guard_field(aggregate, types)

@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).

return_expr(aggregate, alias)

@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.