Search overview
Every resource registered with Rest::resource() answers POST /{resource}/search. The client describes what it wants in one JSON body: the package validates it against the declarations of the resource, checks the policies, builds one query plan and answers a page of rows.
use App\Rest\Controllers\ProductsController;
use Lomkit\Rest\Facades\Rest;
Rest::resource('products', ProductsController::class);
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsTo;
class ProductResource extends Resource
{
public static string $model = Product::class;
public function fields(): array
{
return [
ID::make('id'),
Integer::make('category_id'),
Text::make('name'),
Integer::make('price'),
];
}
public function relations(): array
{
return [
BelongsTo::make('category', CategoryResource::class),
];
}
}
A first search
{
"search": {
"filters": [
{"field": "price", "operator": "<", "value": 100}
]
}
}
{
"current_page": 1,
"data": [
{"id": 1, "category_id": 1, "name": "Desk lamp", "price": 39}
],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 1,
"total": 1,
"meta": {}
}
Every key of search is optional. A body without search answers the first page of the resource, in its default order.
The payload
| Key | What it does | Page |
|---|---|---|
filters | Keep the rows that match conditions on fields, relation paths, pivot fields and related rows | Filters |
sorts | Order the rows by fields, fields of a to-one relation, aggregates or relevance | Sorts and selects |
selects | Choose the fields of each row | Sorts and selects |
includes | Load related rows, each with its own search | Includes and aggregates |
aggregates | Add count, exists, min, max, avg and sum values of a relation | Includes and aggregates |
scopes | Call the local scopes that the resource declares | Scopes and instructions |
instructions | Run the instructions that the resource declares | Scopes and instructions |
text | Run the text search of the resource | Text search |
trashed | Add the soft-deleted rows (with) or return them alone (only) | Text search |
gates | Answer, for each row, whether the user may view, update, delete... it | Gates |
page, limit | Choose the page and its size | Pagination |
{
"search": {
"filters": [
{"field": "price", "operator": ">=", "value": 20},
{"relation": "category", "nested": [{"field": "name", "value": "Lighting"}]}
],
"sorts": [{"field": "price", "direction": "desc"}],
"selects": [{"field": "id"}, {"field": "name"}, {"field": "price"}],
"includes": [{"relation": "category"}],
"gates": ["update", "delete"],
"limit": 10
}
}
- A key that the package does not know inside an object of a search (a filter, a sort, an include,
text...) gives a 422 on that key, named as written:The search.filters.0.operater field is prohibited. - A key that the package does not know at the first level of the body, or directly under
search, is ignored, so that a later release can add keys. filters,nested,sorts,selects,scopes,aggregates,includes,instructionsandgatesmust be JSON lists. An object givesThe search.filters field must be a list.- Every list has a size limit in
rest.limits, see Limits.
The response
A search answers a page:
| Key | Value |
|---|---|
current_page | The page that the response holds |
data | The rows of the page |
from, to | The position of the first and the last row of the page, null on an empty page |
last_page | The last page, from total and per_page |
per_page | The page size: the limit of the search, or the $defaultLimit of the resource |
total | The number of rows that match |
total_capped | Only on a resource that sets $maxTotal, see Capped totals |
meta | The create gate, the keys of searchMeta() and the meta of the instructions, see Meta. {} when empty |
A row holds its fields, in the order of selects or else of fields(), then its aggregates, its gates and its includes. A row whose level includes a to-many relation ends with @more, see Includes and aggregates.
meta, the pivot of a many-to-many row and the gates of a row are always objects: {} when they hold nothing.Pagination
class ProductResource extends Resource
{
public int $defaultLimit = 25;
public function limits(): array
{
return [10, 25, 100];
}
}
{
"search": {
"page": 2,
"limit": 10
}
}
limitmust be one of the values oflimits(),[10, 25, 50]by default. Another value givesThe selected search.limit is invalid.- Without
limit, a page holds$defaultLimitrows, 50 by default.$defaultLimitmust be a value oflimits(), otherwise the first search of the resource throwsInvalidResourceException. pageis an integer of at least 1:The search.page field must be at least 1.pageis at mostintdiv(PHP_INT_MAX, L) + 1,Lbeing the largest value oflimits(), so that the offset of the page stays an integer. With the defaultlimits():The search.page field must not be greater than 184467440737095517.- A page past the last one answers an empty
data. - One page renders at most
rest.limits.sizes.rowsrows, 10,000 by default, its included rows counted from the limits that the request asks for. A search that could render more gets a 422 before any query:The search.includes.0.includes.1.relation field takes the response over the limit of 10000 rows: it can render up to 10550 rows.See Response rows.
Stable order
Every SQL search ends its order with the key of the model, in descending order, unless the order already holds it. "sorts": [{"field": "price"}] runs order by products.price asc, products.id desc. Rows with equal values keep one order across pages, so a row never shows on two pages or on none. Without sorts, the rows follow defaultOrderBy(), the key in descending order by default.
Capped totals
Counting every matching row of a large table costs a full scan. A resource can cap the count:
class LogResource extends Resource
{
public ?int $maxTotal = 10000;
}
{
"current_page": 1,
"data": [...],
"from": 1,
"last_page": 200,
"per_page": 50,
"to": 50,
"total": 10000,
"total_capped": true,
"meta": {}
}
- The count reads at most
$maxTotal + 1rows, without the order of the search. - When more rows match,
totalis$maxTotal,total_cappedistrue, andlast_pagefollows that total. Otherwisetotalis exact andtotal_cappedisfalse. - A page past the capped total still answers its rows, and its
totalis at least the rows before it plus its own. $maxTotalmust be at least the largest value oflimits(), otherwise the first search throwsInvalidResourceException.null, the default, counts every row and leavestotal_cappedout.- A text search on a search engine keeps the total of the engine.
Gates
gates asks the server whether the user may run an ability on each row. The policy of the model answers, once per distinct check.
use Illuminate\Auth\Access\Response;
public function update(User $user, Product $product): Response
{
return $product->price > 100
? Response::deny('Products above 100 are locked.')
: Response::allow();
}
{
"search": {
"gates": ["create", "update"]
}
}
{
"current_page": 1,
"data": [
{"id": 2, "category_id": 1, "name": "Floor lamp", "price": 129, "gates": {"authorized_to_update": {"allowed": false, "message": "Products above 100 are locked."}}},
{"id": 1, "category_id": 1, "name": "Desk lamp", "price": 39, "gates": {"authorized_to_update": {"allowed": true, "message": null}}}
],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 2,
"total": 2,
"meta": {"gates": {"authorized_to_create": {"allowed": true, "message": null}}}
}
gatestakesview,create,update,delete,restore,forceDeleteand the gates that the resource declares ingates(), each once.restoreandforceDeleteare gates only when the model usesSoftDeletes: on another resource they give the 422 of an unknown gate.viewAnyor another name givesThe selected search.gates.0 is invalid., and an ability listed twice givesThe search.gates.0 field has a duplicate value.on each copy.- Each answer is
{"allowed": bool, "message": string|null}.messagecomes from aResponse::deny('...')of the policy. - The
createanswer goes tometa. A row without answers returns"gates": {}. - Includes take
gatestoo, with the abilities of their resource, exceptcreate. update,delete,restoreandforceDeleteanswerfalsewithout calling the policy for a row that their endpoint would refuse: a trashed row forupdateanddelete, or a row of a search engine page thatperimeterQuery()leaves out.- A policy reads the columns that the search loaded. A column that it reads and that
selectsleaves out throwsMissingColumnException: list it inalwaysSelect(). - The key
gatesand the names of the answers come fromrest.gatesinconfig/rest.php. A resource whoseisGatingEnabled()returnsfalseacceptsgatesat the root of a search and answers none. In an include of such a resource,gatesgivesThe search.includes.0.gates field is prohibited: the included resource turns its gates off.
See Gates for declared gates and renamed answers.
Meta
meta holds the create gate, then the keys that the resource adds with searchMeta(), then the meta of the instructions under instructions.
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Lomkit\Rest\Search\SearchContext;
class PostResource extends Resource
{
public function searchMeta(SearchContext $search, LengthAwarePaginator $page): array
{
return [
'views' => array_sum(array_map(fn (Post $post): int => $post->views, $page->items())),
'total' => $page->total(),
'titled' => $search->filtersOn('title'),
];
}
}
{
"search": {
"filters": [{"field": "views", "operator": ">=", "value": 100}],
"gates": ["create"]
}
}
{
"current_page": 1,
"data": [...],
"from": 1,
"last_page": 1,
"per_page": 50,
"to": 2,
"total": 2,
"meta": {
"gates": {"authorized_to_create": {"allowed": true, "message": null}},
"views": 450,
"total": 2,
"titled": false
}
}
searchMeta()returns[]by default. It runs after thesearched()event, with the same page, on the database and on a search engine.- It reads the rows under the same column check as the rest of the response: a column that the search did not load throws
MissingColumnException. List such a column inalwaysSelect(). - The gates key and
instructionsare filled by the package: returning one of them throwsInvalidResourceException,The resource [App\Rest\Resources\PostResource] returns the key [gates] from searchMeta(), which the package fills in the meta of a search page. Rename the key. - It does not run for the rows of an action, a mutation or a deletion.
- An instruction adds its own object under
meta.instructions, see Scopes and instructions.
Refusing a search
The searching() event of the resource receives a SearchContext once the payload is valid. Throw a ValidationException there to answer a 422 before any query runs.
use Illuminate\Validation\ValidationException;
use Lomkit\Rest\Search\SearchContext;
public function searching(SearchContext $search): void
{
if ($search->filtersOn('starts_at') && ! $search->filtersOn('ends_at')) {
throw ValidationException::withMessages(['search.filters' => 'A starts_at filter needs an ends_at filter.']);
}
}
filtersOn($path)andsortsOn($field)tell whether the search filters or sorts on a field, a relation path such asauthor.last_name, or the key of an aggregate.scopes()andincludes()list the names of its scopes and includes, andtextandtrashedhold those parts of the search.- The filters of nested groups and relation groups are read as one flat list: a filter of a relation group takes the relation as a prefix,
posts.status. - Read the validated search with
$request->payload(). A change of thesearchinput after validation does not change the search.
See Events and hooks for searched() and the other events.
Authorization and perimeter
viewAnyis checked on the resource before validation and before any query: a denial answers403with{"message": "This action is unauthorized."}, or the message of the policy, even for a payload that validation would refuse.- Each resource that the payload reaches through an include, an aggregate, a relation filter or a relation sort needs
viewAnytoo, checked the same way. perimeterQuery()of the resource limits the rows at every level: the rows of the search, its includes, its aggregates and the related rows of its filters. See Perimeter.
Errors
A payload that the resource refuses answers 422, with one message per key:
{
"message": "The selected search.limit is invalid.",
"errors": {
"search.limit": ["The selected search.limit is invalid."]
}
}
| Status | When |
|---|---|
403 | viewAny is denied on the resource, or on a resource that the payload reaches |
422 | A key, a field, an operator, a value, a relation or a limit that the resource does not accept |
500 | A declaration error of the resource, such as a $defaultLimit outside limits(), or a column that a policy, map() or searchMeta() reads and the search did not load |
Limits
Page sizes, include limits, capped totals, and the limits of rest.limits on lists, depths, totals, values, response rows, action jobs and metric groups.
Search filters
Keep the rows that match conditions on fields, relation paths, pivot fields and related rows, with the operators that each field type accepts.