Search

Search overview

The search endpoint reads the rows of a resource with filters, sorts, includes, aggregates and pagination, inside the perimeter of the user.

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.

routes/api.php
use App\Rest\Controllers\ProductsController;
use Lomkit\Rest\Facades\Rest;

Rest::resource('products', ProductsController::class);
app/Rest/Resources/ProductResource.php
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),
        ];
    }
}
POST /api/products/search
{
  "search": {
    "filters": [
      {"field": "price", "operator": "<", "value": 100}
    ]
  }
}
200 OK
{
  "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

KeyWhat it doesPage
filtersKeep the rows that match conditions on fields, relation paths, pivot fields and related rowsFilters
sortsOrder the rows by fields, fields of a to-one relation, aggregates or relevanceSorts and selects
selectsChoose the fields of each rowSorts and selects
includesLoad related rows, each with its own searchIncludes and aggregates
aggregatesAdd count, exists, min, max, avg and sum values of a relationIncludes and aggregates
scopesCall the local scopes that the resource declaresScopes and instructions
instructionsRun the instructions that the resource declaresScopes and instructions
textRun the text search of the resourceText search
trashedAdd the soft-deleted rows (with) or return them alone (only)Text search
gatesAnswer, for each row, whether the user may view, update, delete... itGates
page, limitChoose the page and its sizePagination
POST /api/products/search
{
  "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, instructions and gates must be JSON lists. An object gives The 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:

KeyValue
current_pageThe page that the response holds
dataThe rows of the page
from, toThe position of the first and the last row of the page, null on an empty page
last_pageThe last page, from total and per_page
per_pageThe page size: the limit of the search, or the $defaultLimit of the resource
totalThe number of rows that match
total_cappedOnly on a resource that sets $maxTotal, see Capped totals
metaThe 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];
    }
}
POST /api/products/search
{
  "search": {
    "page": 2,
    "limit": 10
  }
}
  • limit must be one of the values of limits(), [10, 25, 50] by default. Another value gives The selected search.limit is invalid.
  • Without limit, a page holds $defaultLimit rows, 50 by default. $defaultLimit must be a value of limits(), otherwise the first search of the resource throws InvalidResourceException.
  • page is an integer of at least 1: The search.page field must be at least 1.
  • page is at most intdiv(PHP_INT_MAX, L) + 1, L being the largest value of limits(), so that the offset of the page stays an integer. With the default limits(): 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.rows rows, 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;
}
200 OK
{
  "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 + 1 rows, without the order of the search.
  • When more rows match, total is $maxTotal, total_capped is true, and last_page follows that total. Otherwise total is exact and total_capped is false.
  • A page past the capped total still answers its rows, and its total is at least the rows before it plus its own.
  • $maxTotal must be at least the largest value of limits(), otherwise the first search throws InvalidResourceException. null, the default, counts every row and leaves total_capped out.
  • 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.

app/Policies/ProductPolicy.php
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();
}
POST /api/products/search
{
  "search": {
    "gates": ["create", "update"]
  }
}
200 OK
{
  "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}}}
}
  • gates takes view, create, update, delete, restore, forceDelete and the gates that the resource declares in gates(), each once. restore and forceDelete are gates only when the model uses SoftDeletes: on another resource they give the 422 of an unknown gate. viewAny or another name gives The selected search.gates.0 is invalid., and an ability listed twice gives The search.gates.0 field has a duplicate value. on each copy.
  • Each answer is {"allowed": bool, "message": string|null}. message comes from a Response::deny('...') of the policy.
  • The create answer goes to meta. A row without answers returns "gates": {}.
  • Includes take gates too, with the abilities of their resource, except create.
  • update, delete, restore and forceDelete answer false without calling the policy for a row that their endpoint would refuse: a trashed row for update and delete, or a row of a search engine page that perimeterQuery() leaves out.
  • A policy reads the columns that the search loaded. A column that it reads and that selects leaves out throws MissingColumnException: list it in alwaysSelect().
  • The key gates and the names of the answers come from rest.gates in config/rest.php. A resource whose isGatingEnabled() returns false accepts gates at the root of a search and answers none. In an include of such a resource, gates gives The 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'),
        ];
    }
}
POST /api/posts/search
{
  "search": {
    "filters": [{"field": "views", "operator": ">=", "value": 100}],
    "gates": ["create"]
  }
}
200 OK
{
  "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 the searched() 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 in alwaysSelect().
  • The gates key and instructions are filled by the package: returning one of them throws InvalidResourceException, 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.

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) and sortsOn($field) tell whether the search filters or sorts on a field, a relation path such as author.last_name, or the key of an aggregate. scopes() and includes() list the names of its scopes and includes, and text and trashed hold 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 the search input after validation does not change the search.

See Events and hooks for searched() and the other events.

Authorization and perimeter

  • viewAny is checked on the resource before validation and before any query: a denial answers 403 with {"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 viewAny too, 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:

422 Unprocessable Content
{
  "message": "The selected search.limit is invalid.",
  "errors": {
    "search.limit": ["The selected search.limit is invalid."]
  }
}
StatusWhen
403viewAny is denied on the resource, or on a resource that the payload reaches
422A key, a field, an operator, a value, a relation or a limit that the resource does not accept
500A 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