ArrayPropertyControllerTrait
Element-level operations on an **embedded array property** of a document, exposed by {@see ArrayPropertyController} as REST sub-resources.
The host controller must be a PropertyController
subclass (it relies on its wiring: $model, $property, $owner, assertProperty(),
checkOwnerArguments(), success(), fail(), and — for self::RESPOND_WITH_OWNER —
beforeModelCall(), afterModelCall(), prepareLang(), prepareSkin()). The targeted
$property must be a field declared in the model's AQL::ARRAYS option.
Each method maps an HTTP verb to a model array operation and returns a standardized response. Common error responses (built by every method through runArrayOp()):
- 400 Bad Request — the configured property is not a declared array field.
- 404 Not Found — the owner document does not exist (or, for hasItem(), the value is not present in the array).
- 422 Unprocessable Entity — the operation does not exist on that property: a
moveItem() or reorderItems() on a
sortedSet, an updateItem() or reorderItems() on a property declaring no item key. The rule is stated once, by the model, which raises anUnsupportedOperationException; the shared skeleton turns every one of them into this single status.
The element value is resolved from the {value} route placeholder when present,
otherwise from the request body (key value) — use the body for complex (object)
values that cannot travel in a URL.
When the property declares an Arango::ITEM_KEY, that value is the key of the
element instead, which is what makes an object addressable from a URL. The two
operations that target an existing element — moveItem() and updateItem() —
then answer 404 when no element carries it: the model guards both into a no-op
(nothing merged, nothing reordered), so the document they return is enough to tell,
without a second query.
What a write answers is the whole array property, never the single element — an element edit renumbers its neighbours, so the array is the only truthful answer. A controller whose owner document carries values derived from the array — totals, a count, a weight — may switch that answer to the owner document itself with self::RESPOND_WITH_OWNER, and recompute those values from self::afterArrayWrite(). The two go together : the hook exists so the recomputation lands before the response is built, and the option exists so the response can carry what the recomputation produced.
Tags
Table of Contents
Constants
- RESPOND_WITH_OWNER : string = 'respondWithOwner'
- The init key deciding what a write answers : the array property (default), or the **owner document** it belongs to.
Properties
- $respondWithOwner : bool
- Whether a write answers the owner document rather than the array property.
Methods
- addItem() : mixed
- Adds one or several values to the array property of a document.
- hasItem() : mixed
- Tests whether the array property of a document contains a value.
- initializeRespondWithOwner() : static
- Reads {@see self::RESPOND_WITH_OWNER} off the init, deciding what a write answers.
- moveItem() : mixed
- Moves an existing value to a given position in the array property.
- removeItem() : mixed
- Removes one or several values from the array property of a document.
- reorderItems() : mixed
- Reorders the array property from a list of item keys — the whole new order in a single request, where {@see moveItem()} moves one element at a time.
- updateItem() : mixed
- Merges a partial patch into the element of the array property carrying the given item key — an **in-place edit**, where {@see moveItem()} only reorders and {@see removeItem()} only drops.
- afterArrayWrite() : void
- Runs after an array write has touched the document, and **before** the response is built. A no-op here, for a subclass to override.
- bodyParam() : mixed
- Reads a single parameter from the parsed request body.
- resolveItemKey() : string|null
- Resolves the item key of the array property — the attribute carried by each element that identifies it — honouring an `$init` override then the model configuration.
- resolveItemValue() : mixed
- Resolves the array element value from the `{value}` route placeholder, falling back to the request body (key `value`) for complex values that cannot be in a URL.
- containsItemKey() : bool
- Tells whether one of the given elements carries `value` under the `itemKey` attribute (a dotted path is supported, like the model side).
- reloadOwner() : object|null
- Re-reads the owner document a write has just changed, **through the projection**.
- respondAfterWrite() : mixed
- Builds the response of every array write : the hook first, the body second.
- respondWithItem() : mixed
- Builds the response of an operation targeting an **existing** element: the updated array property, or a 404 when no element carries the requested item key.
- runArrayOp() : mixed
- Shared skeleton for the array operations: asserts the property is configured and declared as an array field, enriches the init through {@see \oihana\controllers\traits\ModelCallTrait::beforeModelCall()}, verifies the owner document exists, then runs the given operation. Maps thrown exceptions to a standardized failure response.
Constants
RESPOND_WITH_OWNER
The init key deciding what a write answers : the array property (default), or the **owner document** it belongs to.
public
string
RESPOND_WITH_OWNER
= 'respondWithOwner'
🔑 Reach for it through the consuming class, never through this trait —
ArrayPropertyController::RESPOND_WITH_OWNER. PHP 8.2+ refuses a trait
constant accessed directly.
Properties
$respondWithOwner
Whether a write answers the owner document rather than the array property.
public
bool
$respondWithOwner
= false
Declared by the route that mounts the controller, never by a client : it is the shape of a contract, not a per-request preference.
Methods
addItem()
Adds one or several values to the array property of a document.
public
addItem([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
POST /{collection}/{id}/{property} — the value(s) are read from the request
body (key value); an optional side (left/right) controls the insertion end.
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —The updated array property on success (200), or an error response (400/404).
hasItem()
Tests whether the array property of a document contains a value.
public
hasItem([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
GET /{collection}/{id}/{property}/{value} — the value is read from the {value}
placeholder (or the request body for complex values).
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id,value). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —200 when the value is present, 404 when it is absent (or 400/404 on guard failures).
initializeRespondWithOwner()
Reads {@see self::RESPOND_WITH_OWNER} off the init, deciding what a write answers.
public
initializeRespondWithOwner([array<string|int, mixed> $init = [] ]) : static
Parameters
- $init : array<string|int, mixed> = []
-
The controller init.
Return values
staticmoveItem()
Moves an existing value to a given position in the array property.
public
moveItem([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
PATCH /{collection}/{id}/{property}/{value} — the value comes from the {value}
placeholder (or body), the target index from the request body (key position).
Unsupported on a sortedSet property (the sort order overrides positions) → 422.
On a property declaring an item key, {value} is that key and an unknown one
answers 404 — the model rewrites the array unchanged rather than inserting a
null, and the returned document carries the proof.
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id,value). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —The updated array property on success (200), or an error response (400/404/422).
removeItem()
Removes one or several values from the array property of a document.
public
removeItem([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
DELETE /{collection}/{id}/{property}/{value} — the value comes from the {value}
placeholder (or the request body for complex values).
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id,value). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —The updated array property on success (200), or an error response (400/404).
reorderItems()
Reorders the array property from a list of item keys — the whole new order in a single request, where {@see moveItem()} moves one element at a time.
public
reorderItems([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
PUT /{collection}/{id}/{property} — the ordered keys are read from the request
body (key value), like addItem(), the other operation that targets the
property rather than one of its elements.
A partial list reorders what it names and keeps the rest, appended after it;
unknown keys are skipped and an empty list changes nothing — a reorder never
deletes. Requires the property to declare an Arango::ITEM_KEY, and is
unsupported on a sortedSet property → 422 in both cases.
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —The updated array property on success (200), or an error response (400/404/422).
updateItem()
Merges a partial patch into the element of the array property carrying the given item key — an **in-place edit**, where {@see moveItem()} only reorders and {@see removeItem()} only drops.
public
updateItem([ServerRequestInterface|null $request = null ][, ResponseInterface|null $response = null ][, array<string|int, mixed> $args = [] ][, array<string|int, mixed> $init = [] ]) : mixed
PUT /{collection}/{id}/{property}/{value} — {value} is the item key, and the
request body is the patch itself ({"rating":5}, no envelope): the verb already
says the element is being edited, so nothing has to name it again. The merge is
partial — the attributes it carries overwrite theirs, the others are kept.
Requires the property to declare an Arango::ITEM_KEY (or to receive one through
$init) → 422 otherwise: without a key an element could only be designated by
a byte-for-byte copy of itself, which the patch being applied invalidates. An
unknown key answers 404.
Parameters
- $request : ServerRequestInterface|null = null
- $response : ResponseInterface|null = null
- $args : array<string|int, mixed> = []
-
Route placeholders (
id,value). - $init : array<string|int, mixed> = []
-
Optional initialization options.
Tags
Return values
mixed —The updated array property on success (200), or an error response (400/404/422).
afterArrayWrite()
Runs after an array write has touched the document, and **before** the response is built. A no-op here, for a subclass to override.
protected
afterArrayWrite(ServerRequestInterface|null $request, array<string|int, mixed> $args, array<string|int, mixed> $init, object|null $document) : void
🔑 This is the seam the six operations lacked. ModelCallTrait::afterModelCall() is deliberately not invoked by self::runArrayOp() — the operations answer a response rather than a document, so it would have no consistent result to receive. This hook has one : the document the write returned.
⚠️ It runs before the response, which is the whole point. A controller whose owner document carries values derived from the array — totals, a count, a weight — recomputes them here, so that a response carrying the owner (self::RESPOND_WITH_OWNER) states what the write really produced rather than what stood one write ago.
It does not run when the operation answered a failure : an item key matching no element (self::respondWithItem()) touched nothing, so there is nothing to recompute.
🚨 The document it receives is the raw RETURN NEW — hydrated by the model's
alters, but never passed through AQL::FIELDS. Read it for what the write changed ;
never hand it back as a response. That is what the reload behind
self::RESPOND_WITH_OWNER exists for.
Parameters
- $request : ServerRequestInterface|null
-
The current PSR-7 request (null in CLI / test contexts).
- $args : array<string|int, mixed>
-
Route placeholders (
id). - $init : array<string|int, mixed>
-
The enriched init of the operation.
- $document : object|null
-
The document the write returned, or null when it matched nothing.
bodyParam()
Reads a single parameter from the parsed request body.
protected
bodyParam(ServerRequestInterface|null $request, string $key) : mixed
Parameters
- $request : ServerRequestInterface|null
- $key : string
Return values
mixed —The body value, or null when absent.
resolveItemKey()
Resolves the item key of the array property — the attribute carried by each element that identifies it — honouring an `$init` override then the model configuration.
protected
resolveItemKey(Documents $model[, array<string|int, mixed> $init = [] ]) : string|null
Mirrors the model's own resolution, so the controller and the query it triggers
always agree on what {value} designates. A null result means the property is
targeted by value.
Parameters
- $model : Documents
- $init : array<string|int, mixed> = []
Return values
string|nullresolveItemValue()
Resolves the array element value from the `{value}` route placeholder, falling back to the request body (key `value`) for complex values that cannot be in a URL.
protected
resolveItemValue(ServerRequestInterface|null $request, array<string|int, mixed> $args) : mixed
Parameters
- $request : ServerRequestInterface|null
- $args : array<string|int, mixed>
containsItemKey()
Tells whether one of the given elements carries `value` under the `itemKey` attribute (a dotted path is supported, like the model side).
private
containsItemKey(mixed $items, string $itemKey, mixed $value) : bool
The comparison is strict, which is what AQL's == does on a document
attribute: a numeric key requested as the string "1" matches nothing there
either, so both sides agree on what « found » means.
Parameters
- $items : mixed
-
The array property as returned by the write.
- $itemKey : string
-
The identifying attribute.
- $value : mixed
-
The requested key.
Return values
boolreloadOwner()
Re-reads the owner document a write has just changed, **through the projection**.
private
reloadOwner(ServerRequestInterface|null $request, array<string|int, mixed> $args, array<string|int, mixed> $init) : object|null
🚨 The document a write returns is not the one a GET serves, and handing it
back would be a quiet lie. An array write ends on RETURN NEW : the stored
document, hydrated by the model's alters, but never passed through AQL::FIELDS.
It therefore carries no rebuilt url, ignores Filter::TRANSLATE, exposes stored
attributes the projection filters out, and — the one that bites — walks past the
Field::REQUIRES gates. That last failure is not hypothetical : it is the very
incident ReloadWrittenDocumentTrait was written for, on the document writes.
So the owner is read again, the way PropertyControllerGetTrait::get() reads
it : beforeModelCall() poses the request-scoped authorizer and whatever scope a
subclass adds, the model projects, afterModelCall() post-processes. The answer
is identical to a GET by construction, because it is the same call.
⚠️ The skin is the one this controller's own get() would use — not a fixed
one. A surface serving its array only in a wider skin must declare that skin, or
the response will come back without the very property that was just written.
Parameters
- $request : ServerRequestInterface|null
- $args : array<string|int, mixed>
-
Route placeholders (
id). - $init : array<string|int, mixed>
-
The enriched init of the operation.
Return values
object|null —The projected owner document, or null when it reads back as nothing.
respondAfterWrite()
Builds the response of every array write : the hook first, the body second.
private
respondAfterWrite(ServerRequestInterface|null $request, ResponseInterface|null $response, array<string|int, mixed> $args, array<string|int, mixed> $init, object|null $document) : mixed
The order is the reason this method exists. self::afterArrayWrite() may write to the owner document — recomputed totals, a refreshed count — and a response built before it would state the values of one write ago. Every write of this trait therefore ends here, and nowhere else.
Two shapes, decided once by the route rather than per request :
- by default, the array property — what an element write has always answered ;
- under self::RESPOND_WITH_OWNER, the owner document, re-read through
the projection (self::reloadOwner()). One rule then holds across the
surface : a write answers the new truth of the whole document, exactly as the
document
PATCHalready does.
Parameters
- $request : ServerRequestInterface|null
- $response : ResponseInterface|null
- $args : array<string|int, mixed>
-
Route placeholders (
id). - $init : array<string|int, mixed>
-
The enriched init of the operation.
- $document : object|null
-
The document the write returned (
RETURN NEW).
respondWithItem()
Builds the response of an operation targeting an **existing** element: the updated array property, or a 404 when no element carries the requested item key.
private
respondWithItem(ServerRequestInterface|null $request, ResponseInterface|null $response, array<string|int, mixed> $args, array<string|int, mixed> $init, object|null $document, string|null $itemKey, mixed $value) : mixed
The write has already run — it is guarded into a no-op on both sides (nothing
merged by arrayUpdate(), nothing reordered by arrayMove()) — so the document it
returned is enough to tell, at no extra query cost. A property targeted by value
passes a null itemKey and skips the check entirely.
🔑 The 404 is decided before self::respondAfterWrite() is reached, so a key matching nothing neither fires self::afterArrayWrite() nor reloads the owner. The write touched no element : there is nothing to recompute, and nothing to read back.
Parameters
- $request : ServerRequestInterface|null
- $response : ResponseInterface|null
- $args : array<string|int, mixed>
-
Route placeholders (
id). - $init : array<string|int, mixed>
-
The enriched init of the operation.
- $document : object|null
-
The document returned by the write (
RETURN NEW). - $itemKey : string|null
-
The resolved item key, or null when the property is targeted by value.
- $value : mixed
-
The requested item key.
runArrayOp()
Shared skeleton for the array operations: asserts the property is configured and declared as an array field, enriches the init through {@see \oihana\controllers\traits\ModelCallTrait::beforeModelCall()}, verifies the owner document exists, then runs the given operation. Maps thrown exceptions to a standardized failure response.
private
runArrayOp(ServerRequestInterface|null $request, ResponseInterface|null $response, array<string|int, mixed> $args, array<string|int, mixed> $init, callable $operation) : mixed
The existence guard is the gate. The array queries build their own FILTER
and do not read Arango::CONDITIONS — enriching their init would change nothing.
exist() does read it (ExistQueryTrait),
so an owner document outside the scope answers 404 here and the operation is never
reached. That is why the guard runs for every operation, reads included: a
membership answer on a document the caller may not see is itself a disclosure.
The enriched init is handed to the operation as its third argument rather than
captured by the closure — a closure created at the call site captures $init by
value before this method runs, so a captured copy would never see the enrichment.
afterModelCall() is deliberately not invoked here: the operations return a
response, not a document, so the hook would have no consistent result to receive.
Post-processing a read belongs to PropertyControllerGetTrait::get().
Parameters
- $request : ServerRequestInterface|null
- $response : ResponseInterface|null
- $args : array<string|int, mixed>
- $init : array<string|int, mixed>
- $operation : callable
-
fn(mixed $owner, Documents $model, array $init): mixed — performs the model call and returns the response.