GroupTrait uses trait:short
Translates the high-level {@see Arango::GROUP} spec ({@see Group}) into the raw `COLLECT` spec consumed by {@see \oihana\arango\db\operations\aqlCollect()} and {@see \oihana\arango\db\operations\aqlCollectReturn()} in {@see \oihana\arango\models\traits\queries\ListQueryTrait::buildListQuery()}.
It is the COLLECT counterpart of FacetTrait, reusing the same engines:
- FacetAggregator for the aggregate functions (
sum→SUM, …), - the
altengine (alterExpression()) for grouping-key transforms, - the
key()helper to prefix fields with the document reference.
A raw Arango::COLLECT spec is passed through untouched when no Arango::GROUP is supplied, so power users keep full control.
One gate per half, and they do not default alike
by is fail-closed through GroupTrait::$groupable: without a declared
whitelist, nothing is groupable. agg is fail-open through
GroupTrait::$aggregatable: without one, every projected path stays
aggregatable — declaring the whitelist is what closes it, and
GroupTrait::$aggregatablePolicy says whether an undeclared aggregate is
then dropped or refused outright. Both halves are permission-gated the same way,
on the resolved path, so a field hidden from reading is neither a dimension nor
an aggregate.
Tags
Table of Contents
Properties
- $aggregatable : array<string, string|array<int, string>>|null
- Optional whitelist/mapping of aggregatable fields: `urlKey => fieldPath`.
- $aggregatablePolicy : string|null
- What happens to an aggregate absent from {@see GroupTrait::$aggregatable}: one of the {@see AggregatablePolicy} codes.
- $groupable : array<string, string>|null
- Optional whitelist/mapping of groupable dimensions: `urlKey => fieldPath`.
Methods
- bind() : string
- Bind a value to an AQL query variable.
- bindCollection() : string
- Bind a collection name to an AQL query variable.
- binder() : callable(mixed): string
- Returns a binder over `$binds` — the callable {@see Arango::BINDER} carries down to the `alt` engine, so a parameter that arrived with a request becomes a bound value instead of text written into the query.
- bindView() : string
- Bind the model's declared View name (`AQL::VIEW` block, {@see Search::NAME}) to an AQL query variable — collection bind parameters (`@@view`) are valid for View names as well.
- initializeAggregatable() : static
- Initializes the {@see GroupTrait::$aggregatable} whitelist and its policy from the model options.
- initializeGroupable() : static
- Initializes the {@see GroupTrait::$groupable} whitelist from the model options.
- isGroupedQuery() : bool
- Tells whether a list query built from `$init` carries a `COLLECT`.
- prepareCollect() : array<string|int, mixed>
- Resolves the `COLLECT` spec for a list query.
- prepareGroupSort() : string|null
- Builds the `SORT` clause applied to a grouped result, from {@see Group::SORT}.
- summedAggregates() : array<int, string>
- Names the aggregates of a {@see Arango::GROUP} spec that **add up** — the `sum` and the `avg` — and nothing else.
- aggregatableEntry() : string|AggregateExpression|null
- Resolves an aggregate field token against {@see GroupTrait::$aggregatable}, applying {@see GroupTrait::$aggregatablePolicy} when the token is undeclared.
- aggregateExpression() : string|null
- Resolves a declared {@see AggregateExpression} into the operand the aggregate function wraps, or `null` when it must be withdrawn.
- collectAggregate() : array<string|int, mixed>
- Builds the `AQL::AGGREGATE` map from {@see Group::AGG}.
- collectAssign() : array<string|int, mixed>
- groupRelationDimension() : string|null
- Resolves a grouping dimension that reads a value on the document at the other end of a relation, or null when the permission refuses it.
- normalizeAggregate() : array{0: ?string, 1: ?string}
- Normalizes an aggregate definition into a `[ code, field ]` pair.
- normalizeGroupFields() : array<string, string>
- Normalizes {@see Group::BY} into a `[ varName => field ]` map.
Properties
$aggregatable
Optional whitelist/mapping of aggregatable fields: `urlKey => fieldPath`.
public
array<string, string|array<int, string>>|null
$aggregatable
= null
It is the agg counterpart of GroupTrait::$groupable, with one
deliberate difference: it keys on the field token, not on the output
name. In [ 'total' => 'sum:speed' ] the name total is chosen freely by
the client — whitelisting it would mean nothing — while speed is the token
this map resolves (to speed.value, say).
The gate is fail-open: null (no whitelist) means every projected path
stays aggregatable, exactly as before this option existed. Declaring it closes
the gate, and GroupTrait::$aggregatablePolicy says how loudly.
A whitelisted field is further permission-gated (Field::REQUIRES inherited
from the projection), so a field hidden from reading cannot be aggregated on
(MAX/MIN/AVG/SUM leak a bound of its values).
$aggregatablePolicy
What happens to an aggregate absent from {@see GroupTrait::$aggregatable}: one of the {@see AggregatablePolicy} codes.
public
string|null
$aggregatablePolicy
= null
null resolves to AggregatablePolicy::DROP when a whitelist is
declared, and to AggregatablePolicy::OPEN when none is — so a model
that never heard of the option emits the query it always emitted.
$groupable
Optional whitelist/mapping of groupable dimensions: `urlKey => fieldPath`.
public
array<string, string>|null
$groupable
= null
When set, only whitelisted Group::BY keys are allowed and each
resolves to its real field path (decoupling the public group key from the
internal attribute, like SortTrait::$sortable). The gate is
fail-closed: null (no whitelist) means nothing is groupable — a
client key never reaches doc.<key>. A whitelisted dimension is further
permission-gated (Field::REQUIRES inherited from the projection), so a
field hidden from reading cannot be grouped on (no group-by oracle).
Methods
bind()
Bind a value to an AQL query variable.
public
bind(mixed $value[, array<string|int, mixed> &$binds = [] ][, string|null $to = null ]) : string
Parameters
- $value : mixed
-
The value to bind to the query.
- $binds : array<string|int, mixed> = []
-
Reference to the array of existing bind variables.
- $to : string|null = null
-
Optional name of the bind variable. If null, a unique name is generated.
Tags
Return values
string —The formatted bind variable (including the "@" prefix as needed) for use in the query.
bindCollection()
Bind a collection name to an AQL query variable.
public
bindCollection([array<string|int, mixed> &$binds = [] ][, array<string|int, mixed> $init = [] ]) : string
Prepares a bind variable for a collection name. Uses the collection defined in $init or
falls back to $this->collection if none is provided.
Parameters
- $binds : array<string|int, mixed> = []
-
Reference to the array of existing bind variables. If null, a new array is used.
- $init : array<string|int, mixed> = []
-
Optional initialization array with keys:
- Arango::COLLECTION => the collection name to bind
- Arango::NAME => optional bind variable name
Tags
Return values
string —The formatted bind variable representing the collection.
binder()
Returns a binder over `$binds` — the callable {@see Arango::BINDER} carries down to the `alt` engine, so a parameter that arrived with a request becomes a bound value instead of text written into the query.
public
binder([array<string|int, mixed>|null &$binds = null ]) : callable(mixed): string
⚠ Deliberately a function () use ( &$binds ) and not an arrow function:
fn() captures by value, so the bind would land in a copy and the query would
declare a parameter nothing ever fills. That mistake costs a 400 from the
server and is invisible to any assertion made on the emitted AQL — which is
why the closure is built here, once, rather than at each reading point.
Parameters
- $binds : array<string|int, mixed>|null = null
-
Reference to the array of existing bind variables; a
nullis initialised in place.
Return values
callable(mixed): string —A callable registering a value and returning its @name.
bindView()
Bind the model's declared View name (`AQL::VIEW` block, {@see Search::NAME}) to an AQL query variable — collection bind parameters (`@@view`) are valid for View names as well.
public
bindView([array<string|int, mixed> &$binds = [] ]) : string
Parameters
- $binds : array<string|int, mixed> = []
-
Reference to the array of existing bind variables.
Tags
Return values
string —The formatted bind variable representing the View.
initializeAggregatable()
Initializes the {@see GroupTrait::$aggregatable} whitelist and its policy from the model options.
public
initializeAggregatable([array<string|int, mixed> $init = [] ]) : static
The whitelist is normalised through normalizeSortable(), so the three
sortable notations are accepted and may be mixed: the associative
urlKey => fieldPath, the indexed shorthand fieldName (token equals field),
and the indexed alias [ urlKey => fieldPath ].
Parameters
- $init : array<string|int, mixed> = []
-
The model options (
Arango::AGGREGATABLE,Arango::AGGREGATABLE_POLICY).
Return values
staticinitializeGroupable()
Initializes the {@see GroupTrait::$groupable} whitelist from the model options.
public
initializeGroupable([array<string|int, mixed> $init = [] ]) : static
Parameters
- $init : array<string|int, mixed> = []
-
The model options (
Arango::GROUPABLE).
Return values
staticisGroupedQuery()
Tells whether a list query built from `$init` carries a `COLLECT`.
public
isGroupedQuery([array<string|int, mixed> $init = [] ]) : bool
🔑 A grouped row is not a document. After a COLLECT the projection is
made of the declared variables — the dimensions, the aggregates, the count —
and nothing of the collection's own shape survives. Hydrating such a row in
the model's schema keeps only the names that class happens to declare, so the
list and stream entry points read this to decide whether their result is taken
raw.
🚨 The test is the emitted COLLECT, not the requested one. A group spec
whose every dimension is dropped — undeclared, or closed by the permission
gate — and which carries no aggregate emits no COLLECT at all: the query
still returns documents, and they must still be hydrated. The emptiness test
below mirrors, key for key, the one of aqlCollect().
The spec is resolved a second time rather than carried over from ListQueryTrait::buildListQuery(), which already knows the answer: the reading stays self-contained, and no shared signature has to grow a return channel for it. Nothing is bound along the way — BindTrait::binder() builds a throwaway map when it is handed none.
Parameters
- $init : array<string|int, mixed> = []
-
The list query options.
Tags
Return values
bool —True when the built query groups its rows.
prepareCollect()
Resolves the `COLLECT` spec for a list query.
public
prepareCollect([array<string|int, mixed> $init = [] ][, string $docRef = AQL::DOC ][, array<string|int, mixed>|null &$binds = null ]) : array<string|int, mixed>
Translates a friendly Arango::GROUP spec (Group::BY, Group::AGG, Group::COUNT, Group::ALT) into the raw aqlCollect() keys. Falls back to the raw Arango::COLLECT spec (or an empty array) when no group is requested.
Parameters
- $init : array<string|int, mixed> = []
-
The list query options.
- $docRef : string = AQL::DOC
-
The document reference grouping fields are read from.
- $binds : array<string|int, mixed>|null = null
Tags
Return values
array<string|int, mixed> —The raw COLLECT spec (AQL::ASSIGN, AQL::AGGREGATE, AQL::WITH_COUNT).
prepareGroupSort()
Builds the `SORT` clause applied to a grouped result, from {@see Group::SORT}.
public
prepareGroupSort([array<string|int, mixed> $init = [] ][, array<string|int, mixed>|null $availableVars = null ]) : string|null
The sort operates on group/aggregate variable names (never on doc, which
is out of scope after COLLECT): a CSV with a leading - for descending,
e.g. '-count' → count DESC, 'category,-total' → category ASC, total DESC.
Parameters
- $init : array<string|int, mixed> = []
-
The list query options.
- $availableVars : array<string|int, mixed>|null = null
Return values
string|null —The inner sort expression, or null when none.
summedAggregates()
Names the aggregates of a {@see Arango::GROUP} spec that **add up** — the `sum` and the `avg` — and nothing else.
public
summedAggregates([array<string|int, mixed> $init = [] ]) : array<int, string>
🚨 Those are the only ones that compute a figure, and a computed float carries
noise. SUM and AVERAGE add binary floats, and the store hands the result
back as it is : 1380913.4299999992 where every sheet holds cents. A min and a
max return a value some document holds, a count an integer, a dimension a
stored value — none of them is touched, since none was computed.
🔑 Read from the request, not from the compiled spec. The friendly spec
names each aggregate with its code ('sum:amount') ; a raw Arango::COLLECT
spec is code written by hand, whose author decides what it serves — it names
nothing here. The whitelist and the permission gate are not replayed : an
aggregate they refused is absent from the row, and a name absent from a row
costs nothing to shedAggregateNoise().
Parameters
- $init : array<string|int, mixed> = []
-
The list query options.
Return values
array<int, string> —The variable names of the summed and averaged aggregates.
aggregatableEntry()
Resolves an aggregate field token against {@see GroupTrait::$aggregatable}, applying {@see GroupTrait::$aggregatablePolicy} when the token is undeclared.
private
aggregatableEntry(string $field, string $name) : string|AggregateExpression|null
A declared token always resolves to its mapped path, whatever the policy — so
the whitelist doubles as a pure publicKey => fieldPath alias map. An
undeclared one is answered by the policy: passed through
(AggregatablePolicy::OPEN), dropped (AggregatablePolicy::DROP),
or refused (AggregatablePolicy::STRICT). An unrecognised policy code
drops, so a typo closes the gate rather than opening it.
🚨 This gate answers for the whitelist only, never for the permission gate
that follows it. A whitelisted field refused by Field::REQUIRES is dropped in
silence even under STRICT: an error naming a protected field would tell the
client that field exists — the very oracle the permission gate closes.
A declared entry may also be an AggregateExpression rather than a path,
which is handed back as it is: what it reads and what it compiles is decided
further down, by GroupTrait::aggregateExpression(). Only a declared
entry can be one — OPEN answers an undeclared token with the client's own
token, which is a path by construction.
Parameters
- $field : string
-
The field token written by the client (
sum:speed→speed). - $name : string
-
The aggregate output name, quoted in the strict error.
Tags
Return values
string|AggregateExpression|null —The resolved field path, the declared expression,
or null when the aggregate is dropped.
aggregateExpression()
Resolves a declared {@see AggregateExpression} into the operand the aggregate function wraps, or `null` when it must be withdrawn.
private
aggregateExpression(AggregateExpression $expression, string $docRef, array<string|int, mixed> $init) : string|null
🚨 Every path the expression reads is gated, and one refusal is enough. A
path-based aggregate has a single path to check; an expression has several —
that is what it is for. Checking none of them, or only the first, would make a
derived expression the way around Field::REQUIRES: a field closed to the
projection would come back out as a sum, in silence, without a single existing
essay turning red.
⚠ An empty paths() withdraws the aggregate. An expression that declares
no path declares that it reads nothing. Read as "nothing to gate", it would be
precisely the hole above; read as a refusal, a mis-declaration costs the
aggregate and shows. The refusal is silent, like every permission refusal here
— naming a protected field would tell the client it exists.
The attribute-name guard does not apply: an expression is not an attribute name, and its paths are never interpolated — they only feed the gate. What replaces that guard is origin, not trust: an expression is always a declaration of the consumer's own code, reachable only through a public key already on the whitelist, and any value from the request goes through Arango::BINDER.
Parameters
- $expression : AggregateExpression
-
The declared expression.
- $docRef : string
-
The document reference to read from.
- $init : array<string|int, mixed>
-
The query init, carrying the binder and the authorizer.
Return values
string|null —The per-document expression, or null when the aggregate is dropped.
collectAggregate()
Builds the `AQL::AGGREGATE` map from {@see Group::AGG}.
private
collectAggregate(array<string|int, mixed> $group, string $docRef[, array<string|int, mixed> $init = [] ][, array<string|int, mixed>|null &$binds = null ]) : array<string|int, mixed>
An entry of the whitelist may be an AggregateExpression instead of a
path, in which case the engine wraps what the expression compiles rather than
doc.<path> — one function, one computed operand.
Parameters
- $group : array<string|int, mixed>
-
The group spec.
- $docRef : string
-
The document reference.
- $init : array<string|int, mixed> = []
-
The query init.
- $binds : array<string|int, mixed>|null = null
-
The bind map, by reference: an expression binds its values through it.
Tags
Return values
array<string|int, mixed> —[ outName => 'FN(doc.field)' ].
collectAssign()
private
collectAssign(array<string|int, mixed> $group, string $docRef[, array<string|int, mixed> $init = [] ][, array<string|int, mixed>|null &$binds = null ]) : array<string|int, mixed>
Parameters
- $group : array<string|int, mixed>
- $docRef : string
- $init : array<string|int, mixed> = []
- $binds : array<string|int, mixed>|null = null
Return values
array<string|int, mixed>groupRelationDimension()
Resolves a grouping dimension that reads a value on the document at the other end of a relation, or null when the permission refuses it.
private
groupRelationDimension(string $var, array<string|int, mixed> $entry, array<string|int, mixed> $init) : string|null
A grouped query never projects — doc is consumed by the COLLECT, and
returnFields() is not called — so there is no LET to name, unlike the
relational sort. The dimension carries its own traversal instead, written
inline in the COLLECT by buildEdgeGroupExpression().
Three declarations cannot be honoured, and each is refused rather than dropped: a dropped dimension reads as a client typo, while these are faults in the model that only its author can fix.
- the named relation is not declared in
AQL::EDGES, so there is no traversal to compile; - it is not a singular relation. Grouping on a plural one has no sound
answer: kept as an array the dimension groups by the combination, and
unwound before the
COLLECTit counts a multi-vertex document once per vertex, inflating every other aggregate of the sameCOLLECT; - no
Field::PATHsays which field of the related document labels the group.
Permission follows the relation: an explicit Field::REQUIRES on the
dimension wins, otherwise the subject declared on the relation field is
inherited. Grouping by a field hidden from reading would return its
distinct values in clear.
Parameters
- $var : string
-
The dimension key (the URL key).
- $entry : array<string|int, mixed>
-
The groupable definition (
AQL::EDGE,Field::PATH,Field::REQUIRES). - $init : array<string|int, mixed>
-
The request-level init. Reads
Arango::AUTHORIZER.
Tags
Return values
string|null —The dimension expression, or null when refused by permission.
normalizeAggregate()
Normalizes an aggregate definition into a `[ code, field ]` pair.
private
normalizeAggregate(mixed $definition) : array{0: ?string, 1: ?string}
Accepts 'sum:amount' (string) or ['sum','amount'] (list).
Parameters
- $definition : mixed
Return values
array{0: ?string, 1: ?string}normalizeGroupFields()
Normalizes {@see Group::BY} into a `[ varName => field ]` map.
private
normalizeGroupFields(mixed $by) : array<string, string>
- CSV string
'category,status'→[ 'category' => 'category', 'status' => 'status' ]. - list
['category','status']→ same. - assoc
['year' => 'created']→ kept as-is.
Dotted fields yield underscore variable names (address.city → address_city).
Parameters
- $by : mixed