Oihana PHP Arango

helpers

Table of Contents

Namespaces

edges
facets
joins
relations

Functions

assertDocuments()  : void
Ensures that a given value is an instance of {@see Documents}.
authorizeRelationFields()  : array<string, mixed>|null
Drops the relation markers whose edge/join definition is denied by the request-scoped authorizer (definition-level gating, `AQL::REQUIRES` on the definition itself).
authorizeTargetFields()  : array<string|int, mixed>|null
Re-applies the **target model**'s `Field::REQUIRES` gates to a relation's projection (T6).
buildPolymorphicRelationVariable()  : string
Assembles the AQL 'LET' of a *polymorphic* relation — a join or an edge whose target collection is chosen at query time from a discriminator field — shared by {@see \oihana\arango\models\helpers\joins\buildPolymorphicJoinVariable()} and {@see \oihana\arango\models\helpers\edges\buildPolymorphicEdgeVariable()}.
buildTree()  : array<string|int, mixed>
Reconstructs a nested `children[]` tree from a **flat** list of nodes.
buildTreeAlter()  : callable
Builds an {@see \oihana\models\enums\Alter::MAP} callback that reshapes a flat hierarchy projection into a nested `children[]` tree via {@see buildTree()}.
buildVariables()  : void
Builds all fields variables definitions (edges, joins, etc.).
extractFromIDs()  : array<string|int, mixed>
Extract unique "_from" vertex IDs from a list of edge documents.
extractNestedRelations()  : array{edges: array, joins: array}
Extract nested edges and joins from a relation configuration or resolved model.
extractToIDs()  : array<string|int, mixed>
Extract unique "_to" vertex IDs from a list of edge documents.
extractVertexIDs()  : array<string|int, mixed>
Extract unique vertex IDs from a list of edge documents.
getDocuments()  : Documents|null
Resolves a {@see Documents} instance from various types of input definitions.
isAttributeAuthorized()  : bool
Decides whether a query attribute (a filter / facet / group key) is allowed for the current request, by inheriting the permission of the homonymous projection field.
isAuthorized()  : bool
Decides whether a field projection is allowed for the current request.
isPathAuthorized()  : bool
Decides whether a *dotted* query attribute (a filter / facet / group path) is allowed for the current request, by inheriting the projection permission of the **exact sub-field** it targets — not only its root.
isPolymorphic()  : bool
Tells whether a relation definition — a **join** or an **edge** — is *polymorphic*, i.e. its target collection is chosen at query time from a discriminator field of the parent document / start vertex.
normalizeSortable()  : array<string, string|array<int, string>>|null
Normalises a `sortable` whitelist into the canonical `urlKey => fieldPath` associative map consumed by {@see \oihana\arango\models\traits\aql\SortTrait::prepareSort()}.
parseFilterSegment()  : FilterPath|null
Parse a single path segment from hierarchical configuration.
prepareRelationDefinition()  : array<string|int, mixed>|null
Resolves, permission-gates and stamps a relation definition (edge or join) before it is handed to a relation builder — the common preamble shared by the `Filter::EDGE(S)` and `Filter::JOIN(S)` branches of {@see buildVariables()}.
sortRelationVariable()  : string
Generates the internal AQL 'SORT' clause for a relation sub-query (edge or join).
vertexID()  : string|null
Resolves a fully-qualified vertex ID for an ArangoDB edge.

Functions

assertDocuments()

Ensures that a given value is an instance of {@see Documents}.

assertDocuments(mixed $value) : void

This runtime assertion validates type safety for Documents. If the provided value is not an instance of Documents, an UnexpectedValueException is thrown with a descriptive message.

This is especially useful when handling dynamically typed data or container-resolved dependencies, where you want to enforce strict model integrity.

Parameters
$value : mixed

The value to assert as an Documents instance.

Tags
throws
UnexpectedValueException

If the provided value is not an instance of Documents.

example
use oihana\arango\models\helpers\assertDocuments;
use oihana\arango\models\Documents;

$documents = new Documents();

// ✅ Valid: no exception thrown
assertDocuments( $documents );

// ❌ Invalid: throws UnexpectedValueException
assertDocuments( 'not an edges instance' );
// → UnexpectedValueException: The value property must be an instance of Documents (arango).
author

Marc Alcaraz (eKameleon)

version
1.0.0

authorizeRelationFields()

Drops the relation markers whose edge/join definition is denied by the request-scoped authorizer (definition-level gating, `AQL::REQUIRES` on the definition itself).

authorizeRelationFields(array<string, mixed>|null $fields, array<string, mixed>|null $edges, array<string, mixed>|null $joins[, array<string|int, mixed> $init = [] ]) : array<string, mixed>|null

A relation is emitted by two parallel walks that must stay symmetric : the LET sub-query (buildVariables) and the projected key (aqlFields). When a definition declares AQL::REQUIRES and the authorizer denies it, the LET is not emitted — so the matching marker must also disappear from the projected fields, otherwise the RETURN would reference an unbound variable. This helper performs that field-side purge : it is applied at every point where a prepared fields array meets its edges/joins registries, right before buildVariables() / aqlFields() run on them.

Resolution mirrors buildVariables() : the definition is looked up in the registry under the field key, a string value being a one-hop alias to another registry entry. A marker without a resolvable array definition is left untouched (buildVariables skips it anyway — nothing to desynchronize).

The check itself is delegated to isAuthorized() with the whole definition (its AQL::REQUIRES key equals Field::REQUIRES), so the semantics are exactly the field-level ones : no marker → allowed, no authorizer → allowed (fail open), a list of subjects → logical OR. The definition-level gate COMPOSES with the field-level Field::REQUIRES (evaluated downstream) : either level can drop the relation.

The function is pure and idempotent : applying it twice — e.g. once on the LET walk and once on the projection walk of a wrapped reference — yields the same fields, which is precisely what keeps both walks symmetric.

Parameters
$fields : array<string, mixed>|null

The prepared query fields (normalized definitions).

$edges : array<string, mixed>|null

The edges registry paired with the fields.

$joins : array<string, mixed>|null

The joins registry paired with the fields.

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

The request-level init array (reads Arango::AUTHORIZER).

Tags
author

Marc Alcaraz (eKameleon)

Return values
array<string, mixed>|null

The fields with denied relation markers removed.

authorizeTargetFields()

Re-applies the **target model**'s `Field::REQUIRES` gates to a relation's projection (T6).

authorizeTargetFields(array<string|int, mixed>|null $fields, Documents|null $documents[, array<string|int, mixed> $init = [] ]) : array<string|int, mixed>|null

When an edge/join definition declares its own projection (AQL::FIELDS / AQL::SKIN_FIELDS), that ad-hoc field list replaces the target model's $fields — so the Field::REQUIRES markers carried by the target model are not applied, and a field hidden from reading could be re-projected in clear through the relation. This helper closes that hole by dropping every projected field whose source attribute is refused by the target model's own projection (isPathAuthorized() against $documents->fields), mirroring the read-side gate applied everywhere else.

The source attribute — Field::NAME when the field aliases a document attribute, otherwise the output key — is what gets gated, never the output label, so an alias cannot dodge (or borrow) the wrong permission.

Relation-scoped OR alternative — Field::SELF_REQUIRES. A field refused by the target model is nonetheless kept when it declares Field::SELF_REQUIRES and the authorizer grants at least one of its subjects — the relation being a legitimately broader context (reading a sub-field of a resource the caller owns). This override lives only here: first-level model reads never pass through this helper, so it never widens a model's direct reads. The subjects are normalized to a non-empty list of strings before the check, so an empty / malformed value cannot borrow isAuthorized()'s "no subject → true" fail-open and re-open T6.

Fail-open and idempotent: with no target model, no $documents->fields, a field carrying no Field::REQUIRES on the target, or no authorizer injected, the field is kept — and re-running it on a projection that already came from $documents->fields changes nothing.

Parameters
$fields : array<string|int, mixed>|null

The relation's resolved projection (mutated copy returned).

$documents : Documents|null

The resolved target model (reads its $fields).

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

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

Tags
author

Marc Alcaraz (eKameleon)

Return values
array<string|int, mixed>|null

The projection with the target-refused fields removed.

buildPolymorphicRelationVariable()

Assembles the AQL 'LET' of a *polymorphic* relation — a join or an edge whose target collection is chosen at query time from a discriminator field — shared by {@see \oihana\arango\models\helpers\joins\buildPolymorphicJoinVariable()} and {@see \oihana\arango\models\helpers\edges\buildPolymorphicEdgeVariable()}.

buildPolymorphicRelationVariable(string|null $name, array<string|int, mixed> $definition, string $ref, array<string|int, mixed> $init, callable $buildBranch) : string

AQL forbids a computed collection in FOR … IN …, so the relation is compiled as an APPEND of guarded static branches: one sub-query per Arango::MAP entry, each guarded by an equality on the discriminator so only the matching branch yields rows. The $buildBranch callback is the only relation-specific part — it turns a branch definition + its guard into a parenthesized sub-query string (a join wraps buildJoinSubquery(), an edge delegates to the already-parenthesized buildEdgeSubquery()).

Security (fail-closed):

  • Per-branch gate — a branch denied by isAuthorized() (Field::REQUIRES / AQL::REQUIRES) is dropped from the APPEND; its collection is never queried, so neither a value nor an existence bit of the hidden type leaks.
  • Fallback — an optional Arango::FALLBACK branch catches discriminator values matching none of the DECLARED types, guarded by NOT IN [ … ] over all map keys (gated or not), so a document of a denied type routes to nothing, never to the fallback (no oracle).
  • When every branch is dropped the LET holds [], so the projection resolves to null / [] rather than a broken statement.
Parameters
$name : string|null

The relation field name (also the default LET variable name).

$definition : array<string|int, mixed>

The polymorphic definition. Keys:

  • Arango::DISCRIMINATOR (string) Parent / start-vertex field deciding the branch (required).
  • Arango::MAP (array) Non-empty type => relation-definition table (required).
  • Arango::UNIQUE (string|null) Optional LET variable name, overrides $name.
  • Arango::FALLBACK (array|null) Definition for unmatched discriminator values (null = none).
$ref : string

The AQL variable name carrying the discriminator (docRef for a join, the start vertex for an edge).

$init : array<string|int, mixed>

The request-level init array (reads Arango::AUTHORIZER).

$buildBranch : callable

fn(array $branch, string $guard): string — builds one parenthesized, guarded branch sub-query. Called only on array branches that pass the gate.

Tags
throws
UnexpectedValueException

If $name is empty, Arango::MAP / Arango::DISCRIMINATOR is missing or invalid, a map branch is not an array, or Arango::FALLBACK is a non-array, non-null value.

since
1.0.0
author

Marc Alcaraz

Return values
string

The complete AQL 'LET' statement.

buildTree()

Reconstructs a nested `children[]` tree from a **flat** list of nodes.

buildTree(array<string|int, mixed> $flat[, string $parentSource = AQL::_PARENT ][, string|null $rootKey = null ][, string $childrenKey = AQL::CHILDREN ][, string $keyField = Schema::_KEY ]) : array<string|int, mixed>

The flat list is the shape produced by a depth-ranged edge projection (see buildEdgeVariable() with AQL::MAX_DEPTH): every element is an associative row that knows its parent — either through the AQL::WITH_PATH injected _parent key (default), or through a parent field the document already stores (pass its name as $parentSource, e.g. 'broader').

Each returned node gains a $childrenKey (default children) holding its direct children, recursively.

Roots

  • When $rootKey is given, the roots are the nodes whose parent equals it (the start vertex of the traversal — its own key). This is what buildTreeAlter() passes (the document's _key).
  • When $rootKey is null, a node is a root when its parent key is absent from the list (a depth-1 node points at the — unlisted — start vertex).

Robustness

The reconstruction is O(n) and cycle-safe: a node already seen on the current branch is never descended into again (pathological self-referential data cannot cause infinite recursion). A node whose parent is missing simply becomes a root. Each node is expected to have a single parent — with AQL::WITH_PATH this is guaranteed by the traversal's global vertex uniqueness. Rows that are not associative arrays are ignored.

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

The flat list of nodes (associative arrays).

$parentSource : string = AQL::_PARENT

The key holding each node's parent key (default AQL::_PARENT = _parent).

$rootKey : string|null = null

The start-vertex key; null infers the roots.

$childrenKey : string = AQL::CHILDREN

The key under which children are nested (default children).

$keyField : string = Schema::_KEY

The identity key of a node (default Schema::_KEY = _key).

Tags
example
$flat =
[
    [ '_key' => 'mammals' , '_parent' => 'animals' ] ,
    [ '_key' => 'dogs'    , '_parent' => 'mammals' ] ,
    [ '_key' => 'cats'    , '_parent' => 'mammals' ] ,
];
$tree = buildTree( $flat , rootKey: 'animals' ) ;
// [ [ '_key'=>'mammals', '_parent'=>'animals', 'children'=>[
//       [ '_key'=>'dogs', …, 'children'=>[] ], [ '_key'=>'cats', …, 'children'=>[] ] ] ] ]
author

Marc Alcaraz

since
1.5.0
Return values
array<string|int, mixed>

The list of root nodes, each with a nested $childrenKey.

buildTreeAlter()

Builds an {@see \oihana\models\enums\Alter::MAP} callback that reshapes a flat hierarchy projection into a nested `children[]` tree via {@see buildTree()}.

buildTreeAlter([string $parentSource = AQL::_PARENT ][, string $childrenKey = AQL::CHILDREN ][, string $keyField = Schema::_KEY ]) : callable

Declare it on the field that carries the depth-ranged projection:

use oihana\arango\models\enums\Alter ;
use function oihana\arango\models\helpers\buildTreeAlter ;

AQL::FIELDS =>
[
    Prop::DESCENDANTS =>
    [
        Field::FILTER => Filter::EDGES ,
        Field::ALTERS => [ [ Alter::MAP , buildTreeAlter() ] ] ,
    ],
],
AQL::EDGES =>
[
    Prop::DESCENDANTS =>
    [
        AQL::MODEL     => 'concept_links' ,
        AQL::DIRECTION => Traversal::OUTBOUND ,
        AQL::MAX_DEPTH => 5 ,
        AQL::WITH_PATH => true , // provides the `_parent` used to rebuild the tree
    ],
],

At map time the callback:

  • leaves the value untouched when it is not an array (an absent/scalar relation),
  • reads the root from the enclosing document's $keyField (the traversal start vertex — its own _key),
  • returns the nested tree built by buildTree().

The parent link source defaults to AQL::_PARENT (_parent, injected by AQL::WITH_PATH); pass a stored field name instead (e.g. 'broader') when the document already carries its parent.

Parameters
$parentSource : string = AQL::_PARENT

The key holding each node's parent key (default AQL::_PARENT).

$childrenKey : string = AQL::CHILDREN

The key under which children are nested (default children).

$keyField : string = Schema::_KEY

The identity key of a node and of the enclosing document (default Schema::_KEY).

Tags
author

Marc Alcaraz

since
1.5.0
Return values
callable

A callback matching the Alter::MAP signature ( array|object $document, ?Container $container, string $key, mixed $value, array $params, array $context ): mixed.

buildVariables()

Builds all fields variables definitions (edges, joins, etc.).

buildVariables([array<string|int, mixed> &$variables = [] ][, array<string|int, mixed> $fields = [] ][, array<string|int, mixed>|null $edges = [] ][, array<string|int, mixed>|null $joins = [] ][, ContainerInterface|null $container = null ][, string $docRef = AQL::DOC ][, array<string|int, mixed> $init = [] ]) : void

Permission gating happens at TWO composable levels — either one can drop the relation (logical AND across levels, logical OR inside a declared subjects list):

  • Field-level — the FIELDS entry (the relation marker) declares Field::REQUIRES: gates this projection of the relation in this parent. Combined with the symmetric drop in aqlFields(), the field disappears from both the AQL projection and the response — no key in the JSON, no orphan reference in the AQL.
  • Definition-level — the edge/join definition itself declares AQL::REQUIRES: gates the relation wherever the definition is used. The symmetric field-side drop is handled upstream by authorizeRelationFields() at every point where a prepared fields array meets its registries.

The check is uniform: edges, joins, and edge-counts all share the same gating contract. A Filter::EDGES_COUNT companion is gated exactly the same way as the others — it is simply not gated by default because no Field::REQUIRES is declared on it. To gate the count, declare Field::REQUIRES on the count entry itself (or AQL::REQUIRES on the shared definition to gate every usage).

Parameters
$variables : array<string|int, mixed> = []
$fields : array<string|int, mixed> = []
$edges : array<string|int, mixed>|null = []
$joins : array<string|int, mixed>|null = []
$container : ContainerInterface|null = null
$docRef : string = AQL::DOC
$init : array<string|int, mixed> = []
Tags
throws
BindException
ConstantException
ContainerExceptionInterface
NotFoundExceptionInterface
ReflectionException

extractFromIDs()

Extract unique "_from" vertex IDs from a list of edge documents.

extractFromIDs(array<string|int, mixed> $edges) : array<string|int, mixed>

This helper function is a shortcut for extractVertexIds() with $side fixed to Schema::_FROM. It returns a unique list of vertex IDs from the "_from" field in an array of edge documents (objects or associative arrays).

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

Array of edge objects or associative arrays containing _from and _to.

Tags
example
use oihana\arango\models\helpers\extractFromIds;
use org\schema\constants\Schema;

$edges = [
    (object) ['_from' => 'apis/1', '_to' => 'permissions/1'],
    (object) ['_from' => 'apis/1', '_to' => 'permissions/2'],
    ['_from' => 'apis/2', '_to' => 'permissions/1'],
];

$fromIds = extractFromIds($edges);
// Result: ['apis/1', 'apis/2']
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
array<string|int, mixed>

Unique list of "_from" vertex IDs.

extractNestedRelations()

Extract nested edges and joins from a relation configuration or resolved model.

extractNestedRelations(array<string|int, mixed> $config[, object|null $targetModel = null ][, bool|null $isEdge = null ][, ContainerInterface|null $container = null ]) : array{edges: array, joins: array}

This function can work in two modes:

  1. Resolution mode: Resolves target model from config via container, then extracts relations
  2. Direct mode: Extracts relations from an already-resolved target model

In both modes, the function merges the extracted relations with any explicit AQL::EDGES and AQL::JOINS defined in the relation configuration.

Resolution mode (for parseFilterSegment):

$relations = extractNestedRelations(
    config: $edgeConfig,
    isEdge: true,
    container: $container
);

Direct mode (for buildEdgeTraversal/buildJoinTraversal):

$relations = extractNestedRelations(
    config: $edgeConfig,
    targetModel: $alreadyResolvedModel
);
Parameters
$config : array<string|int, mixed>

The relation configuration (edge or join config).

$targetModel : object|null = null

Optional: already-resolved target model.

$isEdge : bool|null = null

Required if $targetModel is null: true for edges, false for joins.

$container : ContainerInterface|null = null

Required if $targetModel is null: DI container.

Tags
throws
ContainerExceptionInterface
NotFoundExceptionInterface
Return values
array{edges: array, joins: array}

Associative array with 'edges' and 'joins' keys.

extractToIDs()

Extract unique "_to" vertex IDs from a list of edge documents.

extractToIDs(array<string|int, mixed> $edges) : array<string|int, mixed>

This helper function is a shortcut for extractVertexIds() with $side fixed to Schema::_TO. It returns a unique list of vertex IDs from the "_to" field in an array of edge documents (objects or associative arrays).

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

Array of edge objects or associative arrays containing _from and _to.

Tags
example
use oihana\arango\models\helpers\extractToIds;
use org\schema\constants\Schema;

$edges = [
    (object) ['_from' => 'apis/1', '_to' => 'permissions/1'],
    (object) ['_from' => 'apis/1', '_to' => 'permissions/2'],
    ['_from' => 'apis/2', '_to' => 'permissions/1'],
];

$toIds = extractToIds($edges);
// Result: ['permissions/1', 'permissions/2']
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
array<string|int, mixed>

Unique list of "_to" vertex IDs.

extractVertexIDs()

Extract unique vertex IDs from a list of edge documents.

extractVertexIDs(array<string|int, mixed> $edges, string $side) : array<string|int, mixed>

This helper function is used to extract either the "_from" or "_to" vertex IDs from an array of edge documents (objects or arrays) and return only the unique IDs.

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

Array of edge objects or associative arrays containing _from and _to.

$side : string

The side to extract, either Schema::_FROM or Schema::_TO.

Tags
example
use oihana\arango\models\helpers\extractVertexIds;
use org\schema\constants\Schema;

$edges =
[
    (object) ['_from' => 'apis/1', '_to' => 'permissions/1'],
    (object) ['_from' => 'apis/1', '_to' => 'permissions/2'],
    ['_from' => 'apis/2', '_to' => 'permissions/1'],
];

$toIds = extractVertexIds($edges, Schema::_TO);
// Result: ['permissions/1', 'permissions/2']

$fromIds = extractVertexIds($edges, Schema::_FROM);
// Result: ['apis/1', 'apis/2']
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
array<string|int, mixed>

Unique list of vertex IDs.

getDocuments()

Resolves a {@see Documents} instance from various types of input definitions.

getDocuments([array<string|int, mixed>|string|Documents|null $definition = null ][, ContainerInterface|null $container = null ][, string $key = Arango::DOCUMENTS ][, Documents|null $default = null ]) : Documents|null

This helper function returns a Documents object from: a direct instance, an array definition, a service name within a PSR-11 container, or falls back to a provided default value.

Behavior:

  • If $definition is a Documents instance, it is returned as-is.
  • If $definition is an array, the function looks for the $key (default: Arango::DOCUMENTS).
  • If $definition is a non-empty string and $container contains a service with that name, the corresponding service is fetched.
  • If none of the above conditions are met, the $default value is returned.
Parameters
$definition : array<string|int, mixed>|string|Documents|null = null

Input definition that may represent an Documents instance, an associative array containing one, or a container service name.

$container : ContainerInterface|null = null

Optional PSR-11 container used to resolve string service names.

$key : string = Arango::DOCUMENTS

Array key to look for when $definition is an array

$default : Documents|null = null

Default Documents instance to return if resolution fails. (defaults to Arango::DOCUMENTS).

Tags
throws
ContainerExceptionInterface

If an error occurs while retrieving the service from the container.

NotFoundExceptionInterface

If the service is not found in the container.

example
use oihana\arango\models\helpers\getDocuments;
use oihana\arango\models\Documents;
use oihana\arango\enums\Arango;
use Psr\Container\ContainerInterface;

$docs = new Documents(['_key' => 'user_1']);

// Example 1: Direct instance
$result = getDocuments($docs);
// → returns the same $docs instance

// Example 2: From array definition
$result = getDocuments([Arango::DOCUMENTS => $docs]);
// → returns the $docs instance from the array

// Example 3: From container service
$container->method('has')->willReturn(true);
$container->method('get')->willReturn($docs);
$result = getDocuments('my.documents.service', $container);
// → returns the $docs instance from the container

// Example 4: Default fallback
$default = new Documents(['_key' => 'fallback']);
$result  = getDocuments(null, null, $default);
// → returns $default
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
Documents|null

Returns the resolved Documents instance or the default value if not found.

isAttributeAuthorized()

Decides whether a query attribute (a filter / facet / group key) is allowed for the current request, by inheriting the permission of the homonymous projection field.

isAttributeAuthorized(string $key, array<string|int, mixed>|null $fields[, array<string|int, mixed> $init = [] ]) : bool

The whitelists that gate what can be queried (AQL::FILTERS, Arango::FACETS, Arango::GROUPABLE) are disjoint from the projection permission (Field::REQUIRES on $fields). Without this bridge, a field declared queryable but hidden from reading could still be filtered / faceted / grouped on, and the presence, the distribution or the order of the results would leak the hidden value (an oracle).

This helper closes that gap the same way SortTrait does for sorting: it looks the attribute up in the projection map $fields and, when that field carries a Field::REQUIRES subject, defers the decision to the shared isAuthorized() gate (backend-agnostic closure injected through $init[Arango::AUTHORIZER]). Devise: « what you cannot read, you cannot query on ».

Resolution rules (aligned on the field-level semantics):

  • $fields is not an array, or the attribute is absent / not an array definition → true (nothing to inherit, no gating).
  • The field definition carries no Field::REQUIREStrue (no gating).
  • Otherwise → the result of isAuthorized() (OR over the subjects list, fail-open when no authorizer is injected).
Parameters
$key : string

The public attribute key (already whitelisted by the surface).

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

The model projection map ($this->fields), keyed by field name.

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

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

Tags
example
$fields = [ 'name' => true , 'salary' => [ Field::REQUIRES => 'hr:read' ] ] ;
isAttributeAuthorized( 'name'   , $fields , $init ) ; // true  (ungated)
isAttributeAuthorized( 'salary' , $fields , [ Arango::AUTHORIZER => fn() => false ] ) ; // false
author

Marc Alcaraz (eKameleon)

since
1.0.0
Return values
bool

true when the attribute may be queried, false when the inherited subject is refused.

isAuthorized()

Decides whether a field projection is allowed for the current request.

isAuthorized(array<string|int, mixed> $definition[, array<string|int, mixed> $init = [] ]) : bool

Reads the optional Field::REQUIRES permission subject(s) declared on the field definition, then defers the actual decision to a backend-agnostic Closure(string $subject): bool injected through $init[Arango::AUTHORIZER].

The framework remains agnostic of the underlying authorization layer (Casbin, OPA, custom, ...) — the consumer is responsible for binding the callable to a real enforcer and a request-scoped user identifier.

Resolution rules:

  • No Field::REQUIRES declared on the definition → true (no gating).
  • Field::REQUIRES resolves to an empty list → true (no gating).
  • No Arango::AUTHORIZER injected, or value is not callable → true (authorization layer disabled, fail open).
  • One or more subjects declared → true if at least one subject is granted by the callable (logical OR).
Parameters
$definition : array<string|int, mixed>

Field definition. Reads Field::REQUIRES.

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

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

Tags
example

Single subject

$definition[ Field::REQUIRES ] = 'users.roles:list' ;
isAuthorized( $definition , [ Arango::AUTHORIZER => fn() => true ] ) ; // true
isAuthorized( $definition , [ Arango::AUTHORIZER => fn() => false ] ) ; // false

OR over a list

$definition[ Field::REQUIRES ] = [ 'users.roles:list' , 'users.roles:admin' ] ;
$init[ Arango::AUTHORIZER ]    = fn( string $s ) : bool => $s === 'users.roles:admin' ;
isAuthorized( $definition , $init ) ; // true (admin matched)
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
bool

true when the projection is allowed, false when every declared subject was refused.

isPathAuthorized()

Decides whether a *dotted* query attribute (a filter / facet / group path) is allowed for the current request, by inheriting the projection permission of the **exact sub-field** it targets — not only its root.

isPathAuthorized(string $path, array<string|int, mixed>|null $fields[, array<string|int, mixed> $init = [] ]) : bool

This is the depth-aware counterpart of isAttributeAuthorized(): instead of gating explode('.', $path)[0] (the root only), it walks the whole path segment by segment, descending through the sub-fields of the structural fields (Field::FIELDS, or the per-skin buckets AQL::SKIN_FIELDS) and reading the optional Field::REQUIRES at every level. A single locked level anywhere on the path denies the whole path — « what you cannot read, you cannot query on ».

The decision at each level is deferred to the shared isAuthorized() gate (backend-agnostic closure injected through $init[Arango::AUTHORIZER]), so the semantics (OR over the subjects list, fail-open when no authorizer is injected) match the field-level gate exactly.

Resolution rules (aligned on isAttributeAuthorized(), extended in depth) :

  • $fields is not an array → true (nothing to inherit, no gating).
  • A segment is absent from the current field map(s), or only declared as a scalar / bool definition → true (no Field::REQUIRES to inherit here).
  • A segment carries a Field::REQUIRES refused by the authorizer → false.
  • Otherwise the walk descends into the sub-fields and continues; a path with no deeper declared sub-fields resolves to true.

Sub-fields resolution (fail-closed union). A structural field can declare its sub-fields under Field::FIELDS and / or under per-skin buckets AQL::SKIN_FIELDS. Because a Field::REQUIRES is a permission (independent of the projection skin), the descent gathers the sub-field from every source and treats it as locked as soon as it is locked in any one of them — a sub-field hidden in a single skin bucket stays gated on every path.

A single-segment path behaves exactly like isAttributeAuthorized() — it is its degenerate case. Each segment is stripped of the [*] array-expansion marker, so the same helper serves the hierarchical filter leaves.

Parameters
$path : string

The dotted public attribute path (e.g. address.city, employee[*].salary).

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

The model projection map ($this->fields), keyed by field name.

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

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

Tags
example
$fields =
[
    'address' =>
    [
        Field::FILTER => Filter::DOCUMENT ,
        Field::FIELDS => [ 'city' => [ Field::REQUIRES => 'geo:read' ] ] ,
    ] ,
] ;
isPathAuthorized( 'address.city' , $fields , [ Arango::AUTHORIZER => fn() => false ] ) ; // false
isPathAuthorized( 'address.zip'  , $fields , [ Arango::AUTHORIZER => fn() => false ] ) ; // true (undeclared → ungated)
author

Marc Alcaraz (eKameleon)

since
1.0.0
Return values
bool

true when every level of the path is authorized, false as soon as one level is refused.

isPolymorphic()

Tells whether a relation definition — a **join** or an **edge** — is *polymorphic*, i.e. its target collection is chosen at query time from a discriminator field of the parent document / start vertex.

isPolymorphic(mixed $definition) : bool

A polymorphic relation replaces the single AQL::MODEL by:

  • Arango::DISCRIMINATOR — the parent field path deciding the branch (e.g. selector.areaScope for a join, kind for an edge) ;
  • Arango::MAP — a non-empty type => relation-definition table, one branch per discriminator value (each branch is itself a regular relation definition) ;
  • Arango::FALLBACK (optional) — the branch used when the discriminator value matches no Arango::MAP key.
Parameters
$definition : mixed

The resolved relation definition (usually an array).

Tags
example
use function oihana\arango\models\helpers\isPolymorphic;
use oihana\arango\enums\Arango;
use oihana\arango\db\enums\AQL;

isPolymorphic([ AQL::MODEL => 'model.warehouse' ]);              // false — regular relation
isPolymorphic([
    Arango::DISCRIMINATOR => 'kind',
    Arango::MAP           => [ 'warehouse' => [ AQL::MODEL => 'model.warehouse' ] ],
]);                                                               // true
since
1.0.0
author

Marc Alcaraz

Return values
bool

true when the definition carries a non-empty Arango::MAP and a non-empty string Arango::DISCRIMINATOR, false otherwise.

normalizeSortable()

Normalises a `sortable` whitelist into the canonical `urlKey => fieldPath` associative map consumed by {@see \oihana\arango\models\traits\aql\SortTrait::prepareSort()}.

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

Three notations are accepted and may be mixed within the same array:

  • Associative (legacy)urlKey => fieldPath. The public ?sort= token (the array key) resolves to the AQL field (the value). The value may be a string or an array path ([ 'address', 'city' ]address.city). This is the historical form and is returned untouched.
  • Indexed shorthand — a plain string fieldName. The token equals the field (urlKey === fieldPath), so the redundant field => field map is avoided.
  • Indexed alias — a single-pair array [ urlKey => fieldPath ], for the rare case where the public token differs from the AQL field (?sort=namegivenName is written [ 'name' => 'givenName' ]). The pair direction is always [ urlKey => fieldPath ], identical to the associative form.

The pair direction [ urlKey => fieldPath ] is preserved across every notation, so the legacy associative map and the new indexed forms speak the same language.

Robustness rules:

  • null (open mode, no whitelist) is returned as-is.
  • In an indexed alias, an entry keyed by a non-string (a pure list such as [ 'address', 'city' ] with no token) is dropped — an alias must carry a token.
  • An indexed value that is neither a string nor an array contributes nothing.

The function is idempotent: re-running it on an already-canonical map returns the same map, so it is safe to apply at construction even when the input is already legacy.

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

The raw sortable definition, in any of the three notations.

Tags
example
use function oihana\arango\models\helpers\normalizeSortable;

// Associative (legacy) — returned untouched.
normalizeSortable( [ 'created' => 'created' , 'name' => 'givenName' ] ) ;
// [ 'created' => 'created' , 'name' => 'givenName' ]

// Indexed shorthand — token equals field.
normalizeSortable( [ '_from' , '_to' , 'created' ] ) ;
// [ '_from' => '_from' , '_to' => '_to' , 'created' => 'created' ]

// Hybrid — a shorthand list with one alias.
normalizeSortable( [ [ 'name' => 'givenName' ] , '_to' , 'created' ] ) ;
// [ 'name' => 'givenName' , '_to' => '_to' , 'created' => 'created' ]

// Open mode — passed through.
normalizeSortable( null ) ; // null
since
1.5.0
author

Marc Alcaraz

Return values
array<string, string|array<int, string>>|null

The canonical urlKey => fieldPath map, or null when $sortable is null.

parseFilterSegment()

Parse a single path segment from hierarchical configuration.

parseFilterSegment(string $segment, array<string|int, mixed> $filters[, array<string|int, mixed> $edges = [] ][, array<string|int, mixed> $joins = [] ][, array<string|int, mixed> $parentPath = [] ][, ContainerInterface|null $container = null ]) : FilterPath|null

This function analyzes a filter path segment and determines its type (simple field, document, array expansion, edge, or join). It validates the segment against the configuration and, for edges and joins, resolves nested relations from target models.

Key Features:

  • Validates array notation consistency (e.g., employee[*] must be of type EDGES/JOINS/ARRAY_EXPANSION)
  • Resolves nested edges/joins from target models for multi-level traversals
  • Supports both explicit relation references (via AQL::RELATION) and implicit (segment key)
  • Accumulates full path for better error reporting

Nested Relations Resolution: For edges, the function:

  1. Gets the edge model from the container
  2. Determines target model based on traversal direction (INBOUND → from, OUTBOUND → to)
  3. Extracts edges/joins from target model
  4. Merges with explicit nested relations from edge configuration

For joins, the function:

  1. Gets the join target model from the container
  2. Extracts edges/joins from target model
Parameters
$segment : string

Current path segment (e.g., "employee[*]", "address", "workLocation")

$filters : array<string|int, mixed>

Current level AQL::FILTERS configuration

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

Available edges configuration at current level

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

Available joins configuration at current level

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

Accumulated path from parent segments for error reporting

$container : ContainerInterface|null = null

DI container for resolving target models and their relations

Tags
throws
RuntimeException

If relation reference is not found in edges/joins configuration

ContainerExceptionInterface

If container encounters an error resolving models

NotFoundExceptionInterface

If target model is not found in container

example
// Simple field
$info = parseFilterSegment('email', ['email' => FilterType::STRING], [], [], []);
// → type: 'string', path: ['email'], nestedEdges: [], nestedJoins: []

// Custom callable
$customFilter = fn($init, &$binds, $doc) => "LOWER($doc.name) == 'test'";
$info = parseFilterSegment('custom', ['custom' => $customFilter], [], [], []);
// → type: Closure, path: ['custom']

// Edge with nested relations
$info = parseFilterSegment(
    'employee[*]',
    ['employee' => ['type' => Filter::EDGES, 'filters' => [...]]],
    ['employee' => ['model' => Models::EMPLOYEE_EDGE]],
    [],
    [],
    $container
);
// → type: 'edges', path: ['employee'], nestedEdges: [...from target model...], nestedJoins: [...]
Return values
FilterPath|null

Parsed segment information with nested relations, or null if segment is not allowed

prepareRelationDefinition()

Resolves, permission-gates and stamps a relation definition (edge or join) before it is handed to a relation builder — the common preamble shared by the `Filter::EDGE(S)` and `Filter::JOIN(S)` branches of {@see buildVariables()}.

prepareRelationDefinition(array<string|int, mixed>|null $registry, int|string $key, array<string|int, mixed> $field, mixed $unique, array<string|int, mixed> $init) : array<string|int, mixed>|null

Steps:

  1. Look the definition up in $registry ($edges or $joins) by $key; a string value is a shortcut reference to another entry, dereferenced once.
  2. Return null when nothing resolves (the caller skips the relation).
  3. Two composable gates (logical AND): Field::REQUIRES on the FIELDS entry ($field) and AQL::REQUIRES on the definition itself. Either one denied returns null, dropping the relation from both the LET walk and — mirrored upstream by authorizeRelationFields() — the projection.
  4. Stamp Field::UNIQUE onto the definition (the caller-provided AQL variable name override).

A Filter::EDGES_COUNT is gated the same way: its count LET is dropped only when the entry (or the shared definition) declares a requirement — otherwise the cardinality stays visible, as knowing it is rarely a leak.

Parameters
$registry : array<string|int, mixed>|null

The relation registry — $edges or $joins.

$key : int|string

The field key naming the relation.

$field : array<string|int, mixed>

The FIELDS entry (read for Field::REQUIRES / gating).

$unique : mixed

The AQL variable name override stamped as Field::UNIQUE.

$init : array<string|int, mixed>

The request-level init array (reads Arango::AUTHORIZER).

Tags
since
1.0.0
author

Marc Alcaraz

Return values
array<string|int, mixed>|null

The prepared definition, or null when it does not resolve or is denied.

sortRelationVariable()

Generates the internal AQL 'SORT' clause for a relation sub-query (edge or join).

sortRelationVariable(array<string|int, mixed>|string|null $definition, string $sortRef, string $defaultRef[, string $defaultProperty = Schema::CREATED ]) : string

This helper interprets a flexible sort definition and constructs the appropriate AQL 'SORT' expression, shared by sortEdgeVariable() and sortJoinVariable(). The only thing those two wrappers customize is which variable reference carries the sort property and which one carries the default fallback property:

  • An explicit sort property (string definition, or AQL::SORT in an array) is sorted on $sortRef (the vertex for an edge, the join document for a join).
  • The fallback (no AQL::SORT provided) sorts by $defaultProperty on $defaultRef in DESC order (the edge for an edge relation, the same join document for a join).
Parameters
$definition : array<string|int, mixed>|string|null

The sort configuration (a $definition array, a legacy string, or null).

$sortRef : string

The AQL variable reference carrying an explicit sort property.

$defaultRef : string

The AQL variable reference carrying the fallback default property.

$defaultProperty : string = Schema::CREATED

The fallback property to sort by (default: 'created').

Tags
example
// Explicit sort on the sortRef (ASC by default)
echo sortRelationVariable( 'name' , 'v' , 'e' );          // SORT v.name ASC

// Explicit sort with order
echo sortRelationVariable( [ AQL::SORT => 'age' , AQL::ORDER => Order::DESC ] , 'v' , 'e' ); // SORT v.age DESC

// Fallback on the defaultRef in DESC
echo sortRelationVariable( null , 'v' , 'e' );            // SORT e.created DESC
Return values
string

The generated AQL 'SORT' clause.

vertexID()

Resolves a fully-qualified vertex ID for an ArangoDB edge.

vertexID(string|null $vertexKey[, Documents|string|null $collection = null ]) : string|null

Converts a vertex key into a complete ArangoDB vertex ID by optionally prefixing it with a collection name.

This is useful when preparing _from or _to fields in edge queries, ensuring the correct collection/key format.

Behavior

  • If $vertexKey is null, the function returns null.
  • If $collection is a Documents instance with a collection property, the returned ID will be prefixed with this collection.
  • If $collection is a string, it will be used directly as the collection prefix.
  • If $collection is null, the function returns the raw $vertexKey.
Parameters
$vertexKey : string|null

The vertex key (document _key) or null.

$collection : Documents|string|null = null

Optional collection prefix, either a string or a Documents instance.

Tags
example
use oihana\arango\models\Documents;
use function oihana\arango\models\helpers\vertexID;

$docs = new Documents();
$doc->collection = 'users';

$fullID = vertexID('123', $docs);
// → 'users/123'

$rawID = vertexID('456');
// → '456'

$stringPrefix = vertexID('789', 'accounts');
// → 'accounts/789'

$stringPrefix = vertexID('users/789', 'accounts');
// → 'users/789'

$stringPrefix = vertexID('accounts/789', $docs);
// → 'accounts/789'

$nullID = vertexID( null , $docs );
// → null
author

Marc Alcaraz (eKameleon)

version
1.0.0
Return values
string|null

Returns the prefixed vertex ID if a collection is provided, otherwise the original key, or null if $vertexKey is null.

On this page

Search results