Oihana PHP Arango

SortTrait uses trait:short, \oihana\controllers\traits\DefaultLangTrait, \oihana\traits\SortDefaultTrait

Turns the textual `?sort=` grammar into an AQL `SORT` expression, and powers distance ordering via the `?near=` anchor.

?sort= grammar

A comma-separated list of keys; a leading - flips a key to descending. Each key is resolved through the model's AQL::SORTABLE whitelist (URL key → AQL field path); a key outside the whitelist is silently dropped. The gate is fail-closed: when a model declares no whitelist ($sortable === null), nothing sorts — a client key never reaches doc.<key>. When no ?sort= is given, the model's SORT_DEFAULT applies, and it too must name whitelisted keys (it flows through the same gate). The synthetic distance / score keys are the exception — they are driven by ?near= / a View search and are resolved upstream of the whitelist, so they sort even without a SORTABLE.

?sort=name,-created   // SORT doc.name ASC, doc.created DESC

Total order (SORT_TIEBREAK)

A pagination only means something under a total order: LIMIT/OFFSET say « the first fifty », and first exists only when no two documents are left level. SORT_DEFAULT answers for the case where nothing is asked for; SORT_TIEBREAK answers for the case where something is — it is appended to every resolved sort, last, so it speaks only on ties:

AQL::SORTABLE      => [ Prop::ID , Prop::NAME ] ,
AQL::SORT_TIEBREAK => Prop::ID ,
// ?sort=name  → SORT doc.name ASC, doc.id ASC
// ?sort=id    → SORT doc.id ASC          (already total, nothing appended)

It closes the order as a whole, so there is one per model, never one per sortable key — and it must be declared in AQL::SORTABLE, since it travels the same gates as any criterion. null (the default) keeps the historical behaviour: a named sort orders exactly what it names, ties included.

Permission gate

A whitelisted key can still be permission-gated, so a field hidden from the projection stays untriable (no sort oracle). The gate is resolved by authorizeSortKey() — inherited from the projection at the resolved field path (address.salary, gated at its exact sub-field via isPathAuthorized(), like groupBy/bounds), or declared explicitly on the $sortable entry:

// Inherited: `salary` is gated in $fields → its sort inherits the same subject.
AQL::SORTABLE => [ Prop::NAME , Prop::SALARY ]

// Explicit: a sortable-only field (absent from the projection) carries its own gate.
AQL::SORTABLE => [ Prop::NAME , 'rank' => [ Field::PATH => 'internal.rank' , Field::REQUIRES => 'staff:read' ] ]

A denied key drops its criterion; no subject (or no authorizer injected) sorts freely.

AQL::SORTABLE notations

The whitelist is normalised by normalizeSortable() into the canonical urlKey => fieldPath map; three forms are accepted and may be mixed:

// Indexed shorthand — token equals field (the common case, no redundant map):
AQL::SORTABLE => [ Prop::_FROM , Prop::_TO , Prop::CREATED , Prop::MODIFIED ]

// Indexed alias — public token differs from the AQL field (?sort=name → givenName):
AQL::SORTABLE => [ [ Prop::NAME => Prop::GIVEN_NAME ] , Prop::CREATED ]

// Associative (legacy) — still supported, returned untouched:
AQL::SORTABLE => [ Prop::CREATED => Prop::CREATED , Prop::NAME => Prop::GIVEN_NAME ]

Multilingual ordering

An entry may aim at one locale of a translations object and fall back:

AQL::FIELDS   => [ 'alternateName' => Filter::TRANSLATE , 'name' => [] ] ,
AQL::SORTABLE => [ 'label' => [ Field::PATH => 'alternateName' , Field::ELSE => 'name' ] ] ,
// ?sort=label&lang=en →
// NOT_NULL(doc.alternateName["en"], doc.alternateName["fr"], doc.name) ASC

The chain is the requested locale (Arango::LANG), then the fallback one, then Field::ELSE; equal links are emitted once, and a chain left empty degrades to the stored path. The fallback is resolved by resolveSortFallbackLang(), the expression built by translatedSortExpression().

Distance ordering (?near=)

?near={ "key":"geo", "latitude":48.85, "longitude":2.35 } provides a reference point and exposes the synthetic sort key distance (Schema::DISTANCE). It is sort-only — it orders, it does not filter (pair it with a geo ?filter= to bound a radius). ?sort= stays the single ordering authority:

  • ?near=… alone (no ?sort=) defaults to SORT <distance> ASC.
  • ?near=…&sort=-distance orders farthest first.
  • ?near=…&sort=distance,name orders by distance then name (you pick the priority).
  • ?near=…&sort=name keeps name only — distance is not auto-appended.
  • ?sort=distance without ?near= is dropped (no anchor).

The key names the geo field, so it is a sort dimension and passes the same fail-closed gate as any sort key: it must be declared in AQL::SORTABLE (which resolves the field path and, via Field::REQUIRES, gates it — a geo field hidden from the projection stays untriable). A missing, unwhitelisted or refused key simply drops the distance sort.

The reference point is bound (@lat / @lng) and the predicate uses DISTANCE(doc.<field>.latitude, doc.<field>.longitude, @lat, @lng), so it is index-accelerated by a two-field GeoIndex. Coordinates are bound only when a distance criterion is actually emitted, so the query never declares an unused bind variable.

Tags
author

Marc Alcaraz

since
1.0.0

Table of Contents

Properties

$sortable  : array<string|int, mixed>|null
The collection (map) of all the sortable fields.
$sortTiebreak  : string|null
The criterion that closes this model's order, in the `?sort=` grammar.

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.
initializeSortable()  : $this
Initialize the sortable array definition.
initializeSortTiebreak()  : $this
Initialize the criterion that closes the model's order.
prepareSort()  : string|null
Prepare the AQL `SORT` expression from the `?sort=` grammar and, optionally, the `?near=` anchor.
prepareNear()  : string|null
Build the `DISTANCE(...)` expression for a `?near=` anchor and bind its coordinates.
authorizeRelationSortKey()  : string|null
Resolves a sortable entry that orders on a field of a **related** document, reached through a relation this model already projects.
authorizeSortKey()  : string|null
Resolve a whitelisted sort entry to its AQL field expression, gated by permission.
isSortAuthorized()  : bool
Decide whether a sort/near field is granted for the request.
isTranslatedSortEntry()  : bool
Decides whether a sortable entry orders on a **multilingual** field — one whose stored value is a translations object (`{ fr: "…", en: "…" }`) rather than the text to compare.
resolveSortEntry()  : array{0: mixed, 1: mixed}
Resolve a whitelisted sort entry to its `[ fieldPath, requires ]` pair.
resolveSortFallbackLang()  : string|null
Resolve the **fallback** language of a multilingual sort entry — the locale used when the requested one is absent from a document, or when the call requests none.
sortCriterion()  : string|null
Resolves one sort criterion through the two gates every key travels.
sortTiebreakOrders()  : array<int, string>
The criteria that close the order, appended after everything the caller asked for.
splitSortToken()  : array{0: string, 1: string}
Splits a sort token into the key it names and the direction it asks for.
translatedSortExpression()  : string
Build the ordering expression of a multilingual entry: the requested locale, then the fallback one, then the field named by `Field::ELSE`.

Properties

$sortable

The collection (map) of all the sortable fields.

public array<string|int, mixed>|null $sortable = null

$sortTiebreak

The criterion that closes this model's order, in the `?sort=` grammar.

public string|null $sortTiebreak = null

null — the default — keeps the historical behaviour : a named sort orders exactly what it names, ties included.

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
throws
BindException

If the provided bind variable name is invalid.

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
throws
BindException

If the bind variable name is invalid.

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 null is 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
throws
BindException

If the bind variable name is invalid.

Return values
string —

The formatted bind variable representing the View.

initializeSortable()

Initialize the sortable array definition.

public initializeSortable([array<string|int, mixed> $init = [] ]) : $this

The raw definition (from the AQL::SORTABLE init key, or the property default) is normalised through normalizeSortable() into the canonical urlKey => fieldPath map. Three interchangeable notations are accepted and may be mixed: the legacy associative urlKey => fieldPath, the indexed shorthand fieldName (token equals field), and the indexed alias [ urlKey => fieldPath ]. null is preserved and means fail-closed (no whitelist → nothing client sorts). The normalisation is idempotent.

Parameters
$init : array<string|int, mixed> = []
Return values
$this

initializeSortTiebreak()

Initialize the criterion that closes the model's order.

public initializeSortTiebreak([array<string|int, mixed> $init = [] ]) : $this

A string in the ?sort= grammar, so it may name several keys ('id,additionalType') and a direction ('-id'). null disables the mechanism entirely — a model that declares nothing behaves exactly as before.

Parameters
$init : array<string|int, mixed> = []
Return values
$this

prepareSort()

Prepare the AQL `SORT` expression from the `?sort=` grammar and, optionally, the `?near=` anchor.

public prepareSort([array<string|int, mixed> $init = [] ][, array<string|int, mixed>|null $sortable = null ][, string $docRef = AQL::DOC ][, array<string|int, mixed>|null &$binds = null ]) : string|null

Each comma-separated criterion in Arango::SORT is resolved against $sortable (URL key → AQL field path); a leading - makes it descending. The synthetic distance key (Schema::DISTANCE) is resolved from Arango::NEAR and only honored when $binds is provided (so the reference point can be bound).

🔑 A criterion list that resolved to nothing is treated as no sort at all. The gates below drop what they refuse — an unlisted key, a key whose field the caller may not read — and a request whose every criterion was dropped used to leave the query with no SORT, because $sortDefault is only read when Arango::SORT is absent. The answer then came back in whatever order the store had at hand, and a LIMIT/OFFSET walk over it could serve one document twice and another never. Falling back on the model's own default restores a deterministic order — without ever honoring the refused key, since the default travels through the very same gates. A model declaring no default still answers unordered : there is nothing to fall back on.

🔑 The model's tiebreaker closes whatever the caller asked for. A named sort replaces the default outright — and with it the criterion the default carried to break its ties. AQL::SORT_TIEBREAK is therefore appended to the resolved criteria, last and only there, unless the order already names it. It is added after the fallback below, so that a sort which resolved to nothing still gets the default rather than the tiebreaker alone. A model declaring none keeps the historical behaviour — see sortTiebreakOrders().

🔑 An empty ?sort= counts as nothing asked for. '' is not null, so it used to reach the grammar, resolve to no criterion, and cost the model its default order — while the score and the distance branches already read it as « no sort given ». All three now agree.

Parameters
$init : array<string|int, mixed> = []

Per-call parameters. Reads Arango::SORT (grammar) and Arango::NEAR (geo anchor).

$sortable : array<string|int, mixed>|null = null

URL-key → field-path whitelist. Defaults to $this->sortable.

$docRef : string = AQL::DOC

The document variable the fields hang off (default doc).

$binds : array<string|int, mixed>|null = null

Bind variables, populated by reference. Required to enable distance/?near= sorting.

Tags
throws
BindException

When a bound coordinate cannot be registered.

ValidationException
example

Plain field sort

$model->prepareSort( [ Arango::SORT => 'name,-created' ] ) ;
// "doc.name ASC, doc.created DESC"

A named sort closed by the model's tiebreaker

$model->sortTiebreak = 'id' ;
$model->prepareSort( [ Arango::SORT => 'name' ] ) ;
// "doc.name ASC, doc.id ASC"

Distance sort (nearest first) via ?near=

$binds = [] ;
$model->prepareSort
(
    [ Arango::NEAR => [ FilterParam::KEY => 'geo' , 'latitude' => 48.85 , 'longitude' => 2.35 ] ] ,
    binds : $binds
) ;
// "DISTANCE(doc.geo.latitude, doc.geo.longitude, @lat, @lng) ASC"

Distance then name

$model->prepareSort
(
    [ Arango::SORT => 'distance,name' , Arango::NEAR => [ ... ] ] ,
    binds : $binds
) ;
// "DISTANCE(...) ASC, doc.name ASC"
Return values
string|null —

The SORT body (without the SORT keyword), or an empty string when nothing sorts.

prepareNear()

Build the `DISTANCE(...)` expression for a `?near=` anchor and bind its coordinates.

protected prepareNear(array<string|int, mixed> $near, array<string|int, mixed>|null &$binds[, string $docRef = AQL::DOC ][, array<string|int, mixed> $init = [] ]) : string|null

The key of the payload names the geo field to order by distance from, so it is a sort dimension and travels through the same fail-closed gate as any sort key: it must be declared in $this->sortable (URL key → geo field path) and it inherits (or declares) a Field::REQUIRES permission — a geo field hidden from the projection stays untriable (no distance oracle). Returns null when the key is missing, is not whitelisted, is refused by permission, or the coordinates are incomplete.

Parameters
$near : array<string|int, mixed>

The ?near= payload ({ key, latitude, longitude }), already array-checked by the caller.

$binds : array<string|int, mixed>|null

Bind variables, populated by reference.

$docRef : string = AQL::DOC

The document variable the fields hang off.

$init : array<string|int, mixed> = []

The request-level init. Reads Arango::AUTHORIZER.

Tags
throws
BindException

When a bound coordinate cannot be registered.

Return values
string|null —

DISTANCE(doc.<field>.latitude, doc.<field>.longitude, @lat, @lng) or null.

authorizeRelationSortKey()

Resolves a sortable entry that orders on a field of a **related** document, reached through a relation this model already projects.

private authorizeRelationSortKey(array<string|int, mixed> $entry, array<string|int, mixed> $init) : string|null

The projection of a Filter::EDGE field emits a LET, and the compiled query places every LET before the SORT — so ordering on the related document is a matter of naming that variable, not of traversing again:

LET authorRef = ( FOR v IN OUTBOUND doc articles_authors RETURN … )
SORT FIRST( authorRef ).name ASC

Which is why the entry names the projected field (AQL::EDGE => 'author', a key of $this->fields) rather than an edge collection: the sort reuses the traversal the projection already performs. One traversal serves both.

Three declarations cannot be honoured, and each is refused rather than dropped — a dropped criterion looks like a client typo, while these are faults in the model that only its author can fix:

  • the named field is not projected, so there is no LET to name;
  • it is not a singular relation — ordering on a plural one asks which of the related documents decides, a question the declaration does not answer;
  • it carries no declared Field::UNIQUE, so its variable is the generated random name and cannot be designated.

Permission follows the projected field: an explicit Field::REQUIRES on the sortable entry wins, otherwise the subject of the relation field is reused. What you cannot read, you cannot order by — otherwise the order betrays it.

Parameters
$entry : array<string|int, mixed>

The sortable definition (AQL::EDGE, Field::PATH, Field::REQUIRES).

$init : array<string|int, mixed>

The request-level init. Reads Arango::AUTHORIZER.

Tags
throws
ValidationException

When the declaration cannot be honoured.

Return values
string|null —

The FIRST( <variable> ).<path> expression, or null when refused by permission.

authorizeSortKey()

Resolve a whitelisted sort entry to its AQL field expression, gated by permission.

private authorizeSortKey(string $key, mixed $entry, array<string|int, mixed> $init, string $docRef) : string|null

The entry (the $sortable[$key] value) is either a plain field path — a string or an array path ([ 'address', 'city' ]) — or an explicit definition (an associative array carrying Field::PATH and/or Field::REQUIRES). The permission subject is resolved in two steps, aligned on the projection's Field::REQUIRES:

  • explicit — a Field::REQUIRES declared on the entry itself takes priority;
  • inherited — otherwise the subject of the homonymous field declared in $this->fields is reused, so « what you cannot read, you cannot sort on ».

When a subject is resolved and isAuthorized() denies it, the key is refused (null) and the caller drops the criterion — a field hidden from the projection stays untriable (no sort oracle). No subject, or no authorizer injected, sorts freely (fail-open — exactly the field-level semantics).

Parameters
$key : string

The public URL key (already resolved against the whitelist).

$entry : mixed

The $sortable[$key] value (path or explicit definition).

$init : array<string|int, mixed>

The request-level init. Reads Arango::AUTHORIZER.

$docRef : string

The document variable the field hangs off.

Tags
throws
ValidationException
Return values
string|null —

The doc.<field> expression, or null when the sort is refused.

isSortAuthorized()

Decide whether a sort/near field is granted for the request.

private isSortAuthorized(string $path, mixed $requires, array<string|int, mixed> $init) : bool

Two paths, mirroring the projection's own gating:

  • explicit (Façon A) — an entry that declared its own Field::REQUIRES is run through isAuthorized();
  • inherited (Façon B) — otherwise the Field::REQUIRES is inherited from the projection at the resolved $path via isPathAuthorized(), which descends Field::FIELDS / AQL::SKIN_FIELDS and strips [*], so a dotted/aliased path (address.salary) is gated at its exact sub-field — never at the homonym of the URL key.

Both fail open: no explicit subject with a projection that carries no Field::REQUIRES on the path (or no authorizer injected) sorts freely — exactly the field-level semantics, symmetric with ?filter=.

Parameters
$path : string

The resolved field path (address.salary, location.point, …).

$requires : mixed

The explicit Field::REQUIRES subject(s) declared on the entry, or null.

$init : array<string|int, mixed>

The request-level init. Reads Arango::AUTHORIZER.

Return values
bool —

true when the field may be sorted on, false when refused.

isTranslatedSortEntry()

Decides whether a sortable entry orders on a **multilingual** field — one whose stored value is a translations object (`{ fr: "…", en: "…" }`) rather than the text to compare.

private isTranslatedSortEntry(mixed $entry, string $path) : bool

Two declarations say so, and they follow the two steps Field::REQUIRES already follows — inherited first, explicit second:

  • inherited — the resolved path names a field declared Filter::TRANSLATE in $this->fields, the very declaration that makes the projection translate it;
  • explicit — the entry carries Field::FILTER => Filter::TRANSLATE itself, for a field that is sortable but not projected (nothing to inherit from).

⚠ The inherited form reads a root field (a path of a single segment). A translated field nested inside a structural one is not walked into: declare Field::FILTER on the entry instead. A miss is not a hole — the entry then behaves as the stored path it has always been.

Parameters
$entry : mixed

The $sortable[$key] value (path or explicit definition).

$path : string

The resolved (dotted) field path.

Return values
bool —

true when the entry orders on a translations object.

resolveSortEntry()

Resolve a whitelisted sort entry to its `[ fieldPath, requires ]` pair.

private resolveSortEntry(string $key, mixed $entry) : array{0: mixed, 1: mixed}

The entry (the $sortable[$key] value) is either a plain field path — a string or an array path ([ 'address', 'city' ]) — or an explicit definition (an associative array carrying Field::PATH and/or Field::REQUIRES). Only the explicit Field::REQUIRES (Façon A) is returned here; the inherited permission (Façon B) is decided by isSortAuthorized() against the resolved $path — never against the URL key — so a dotted/aliased path (salary → address.salary) is gated at its exact (sub-)field, symmetric with groupBy/bounds and free of the "wrong homonym" pitfall.

Shared by the textual ?sort= grammar and the ?near= distance anchor, so both resolve a geo/scalar field the same way.

Parameters
$key : string

The public URL key (already resolved against the whitelist).

$entry : mixed

The $sortable[$key] value (path or explicit definition).

Return values
array{0: mixed, 1: mixed} —

The [ fieldPath, requires ] pair (requires is the explicit subject, or null when the entry declares none).

resolveSortFallbackLang()

Resolve the **fallback** language of a multilingual sort entry — the locale used when the requested one is absent from a document, or when the call requests none.

private resolveSortFallbackLang(mixed $entry, array<string|int, mixed> $init) : string|null

Three declaration sites, from the most local to the most general; the first that answers wins:

  1. the sortable entry (Field::DEFAULT_LANG),
  2. the model ($this->defaultLang, see DefaultLangTrait),
  3. the host, pushed per call ($init[ Arango::DEFAULT_LANG ]).

⚠ The model outranks the host on purpose. What the host pushes is a default, and a default must never override an explicit declaration — otherwise a model would change behaviour depending on which site loads it, without a line of it moving. Arango::LANG (the requested language) is the opposite case: an instruction, and it wins over all three.

Parameters
$entry : mixed

The $sortable[$key] value.

$init : array<string|int, mixed>

The request-level init. Reads Arango::DEFAULT_LANG.

Tags
throws
ValidationException

When a declared tag is not a valid language code.

Return values
string|null —

The lowercased fallback tag, or null when none is declared.

sortCriterion()

Resolves one sort criterion through the two gates every key travels.

private sortCriterion(string $key, string $order, array<string|int, mixed>|null $sortable, array<string|int, mixed> $init, string $docRef) : string|null

Whitelist gate (fail-closed) : a key is honoured only when the model declares it in $sortable. No whitelist (null) means nothing sorts — the key never reaches doc.<key>.

Permission gate : a field hidden from the projection stays untriable, so the order cannot become an oracle on what the projection withholds. A refused key drops its criterion.

Shared by the client's own criteria and by the tiebreaker, so that neither can reach a field the other could not.

Parameters
$key : string

The URL key, already stripped of its direction.

$order : string

ASC or DESC.

$sortable : array<string|int, mixed>|null

The whitelist in force for this call.

$init : array<string|int, mixed>

The request-level init. Reads Arango::AUTHORIZER.

$docRef : string

The document variable the fields hang off.

Tags
throws
ValidationException
Return values
string|null —

The <field> <order> criterion, or null when either gate refuses it.

sortTiebreakOrders()

The criteria that close the order, appended after everything the caller asked for.

private sortTiebreakOrders(array<string, bool> $named, array<string|int, mixed>|null $sortable, array<string|int, mixed> $init, string $docRef) : array<int, string>

🚨 A pagination only means something under a total order. LIMIT and OFFSET say « the first fifty », then « the next fifty », and first exists only when no two documents are left level. A sort on a non-unique key orders the groups and leaves their inside free : the store is at liberty there, two pages may serve one document twice and another never, and it happens in 200 with nothing in the log. The model's SORT_DEFAULT closes that hole when nothing is asked for ; this closes it when something is.

🔑 It closes the order as a whole, so there is one per model — never one per sortable key. And it is appended to every sort the model serves, including the synthetic distance and score, where ties are the rule rather than the exception : two addresses equally far from a point, two documents holding a term equally often.

🔑 Except when the order is already total, which is a property of the criteria list, not of any one key : an order that already names the tiebreaker cannot be refined by naming it twice. A key unique on one collection is not unique on the next — id closes a collection of one type and leaves an overlapping one open — which is why the model declares what closes its own order and no universal rule can.

The tiebreaker travels the same two gates as any criterion (sortCriterion()), so a model naming a key its whitelist does not carry closes nothing at all — in silence, which reads as settled. Declare the tiebreaker in AQL::SORTABLE.

Parameters
$named : array<string, bool>

The keys the order already carries.

$sortable : array<string|int, mixed>|null

The whitelist in force for this call.

$init : array<string|int, mixed>

The request-level init.

$docRef : string

The document variable the fields hang off.

Tags
throws
ValidationException
Return values
array<int, string> —

The criteria to append, empty when there is nothing to close.

splitSortToken()

Splits a sort token into the key it names and the direction it asks for.

private splitSortToken(string $token) : array{0: string, 1: string}

A leading - flips the criterion to descending and is stripped ; anything else ascends. Shared so that the client's grammar and the model's own declarations are read the same way.

Parameters
$token : string

A non-empty sort token ('name', '-created').

Return values
array{0: string, 1: string} —

The [ key , order ] pair.

translatedSortExpression()

Build the ordering expression of a multilingual entry: the requested locale, then the fallback one, then the field named by `Field::ELSE`.

private translatedSortExpression(mixed $entry, string $path, array<string|int, mixed> $init, string $docRef) : string
SORT NOT_NULL(doc.alternateName["en"], doc.alternateName["fr"], doc.name) ASC

The locale is a bracket accessor rather than a dotted one, uniformly: a tag carrying a dash reads as a subtraction in dot notation (doc.alternateName.pt-BR), and one shape for every tag beats a shape that depends on the tag. The tag is written verbatim — an attribute name can never be bound — hence the guards.

Links are dropped rather than duplicated: a requested locale equal to the fallback yields two terms, not three. Field::ELSE is optional; without it a document that has no translation at all orders on null, which is where it ordered before.

⚠ Field::ELSE names another field, so it passes the same permission gate as any sort key — an unreadable one is dropped from the chain instead of leaking its values through the order.

⚠ When nothing answers — no requested locale, no fallback, no Field::ELSE — the expression is the stored path itself, exactly what an ordinary entry would emit. An incomplete declaration degrades to today's behaviour; it never drops the criterion in silence.

Parameters
$entry : mixed

The $sortable[$key] value.

$path : string

The resolved (dotted) path of the translations object.

$init : array<string|int, mixed>

The request-level init. Reads Arango::LANG and Arango::DEFAULT_LANG.

$docRef : string

The document variable the fields hang off.

Tags
throws
ValidationException

When a language tag or the Field::ELSE path is invalid.

Return values
string —

The ordering expression.

On this page

Search results