Search

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.

A filter reads its field through the field's declaration: the field decides whether it can be filtered, which operators it accepts and how its value is validated.

app/Rest/Resources/PostResource.php
use Illuminate\Contracts\Database\Eloquent\Builder;
use Lomkit\Rest\Fields\Boolean;
use Lomkit\Rest\Fields\Date;
use Lomkit\Rest\Fields\Enum;
use Lomkit\Rest\Fields\ID;
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Text;
use Lomkit\Rest\Relations\BelongsTo;
use Lomkit\Rest\Relations\BelongsToMany;
use Lomkit\Rest\Relations\HasMany;

public function fields(): array
{
    return [
        ID::make('id'),
        Text::make('title')->filterable(['=', 'like']),   // = and like only
        Text::make('summary')->nullable(),                 // the Text operators, plus is null and is not null
        Text::make('slug')->filterable(false),             // refused in filters
        Enum::make('status'),
        Boolean::make('featured'),
        Integer::make('views'),
        Date::make('published_on'),
    ];
}

public function relations(): array
{
    return [
        BelongsTo::make('author', AuthorResource::class),
        HasMany::make('comments', CommentResource::class),
        BelongsToMany::make('tags', TagResource::class)->pivotFields([
            Integer::make('weight'),
        ]),
    ];
}

Conditions

POST /api/posts/search
{
  "search": {
    "filters": [
      {"field": "views", "operator": ">", "value": 100},
      {"field": "featured", "value": true}
    ]
  }
}

A filter holds field, operator, value and type:

  • field names a filterable field of the resource, a relation path such as author.last_name, or a pivot field such as tags.pivot.weight.
  • operator is one of the operators of the field. Without it, the filter compares with =.
  • value is validated for the operator and the type of the field.
  • type is and, the default, or or: an or filter is combined with the filter before it by or.
POST /api/posts/search
{
  "search": {
    "filters": [
      {"field": "status", "value": "published"},
      {"field": "featured", "value": true, "type": "or"}
    ]
  }
}

Operators

The operators depend on the type of the field:

FieldOperators
ID, Enum=, !=, in, not in
Text=, !=, like, not like, ilike, not ilike, in, not in
Number, Integer=, !=, >, >=, <, <=, between, not between, in, not in
Boolean=, !=
Date, DateTime=, !=, >, >=, <, <=, between, not between
Jsonnone
  • A nullable() field adds is null and is not null to the operators of its type.
  • filterable([...]) narrows the operators of a field to the listed ones. Each one must be an operator of the field: Boolean::make('featured')->filterable(['>']) throws InvalidFieldException.
  • On a field that does not accept =, such as filterable(['like']), operator is required.
  • The operators are the cases of Lomkit\Rest\Fields\Filtering\FilterOperator.
POST /api/posts/search
{
  "search": {
    "filters": [
      {"field": "title", "operator": "like", "value": "%Laravel%"},
      {"field": "views", "operator": "between", "value": [100, 500]},
      {"field": "status", "operator": "in", "value": ["published", "scheduled"]},
      {"field": "summary", "operator": "is not null"}
    ]
  }
}

Values

OperatorValue
=, !=, >, >=, <, <=A value of the field's type: a string for Text, an integer for Integer, a number for Number, a boolean for Boolean, a date for Date and DateTime, a value of the enum for Enum, a key of the model's key type for ID
like, not like, ilike, not ilikeA string of at most rest.limits.lengths.patterns characters, 255 by default, or rest.limits.lengths.values when it is smaller
in, not inA list of at least one value, at most rest.limits.sizes.values values (100 by default), each of the field's type
between, not betweenA list of exactly two values of the field's type
is null, is not nullNo value
  • = and != refuse null. Use is null or is not null on a nullable field: {"field": "summary", "operator": "is null"}.
  • A Date value is compared as the start of its day. A Date or DateTime value is written in the date format of the model before the comparison.
  • A string value, and each string of a list, has at most rest.limits.lengths.values characters, 1,000 by default, whatever the operator: The search.filters.0.value field must not be greater than 1000 characters.
  • The error of an entry of a list goes under its position: search.filters.0.value.1.

Case and databases

  • like and not like follow the database: case-sensitive on PostgreSQL, the collation of the column on MySQL (case-insensitive on the default _ci collations), and the case of ASCII letters only is ignored on SQLite.
  • ilike and not ilike ignore case. PostgreSQL runs ilike; MySQL and SQLite run like. On MySQL, ilike stays case-sensitive on a column with a binary or case-sensitive collation.

Groups

nested groups filters, which then combine among themselves first. The group itself takes a type.

POST /api/posts/search
{
  "search": {
    "filters": [
      {"field": "status", "value": "published"},
      {
        "nested": [
          {"field": "views", "operator": ">", "value": 1000},
          {"field": "featured", "value": true, "type": "or"}
        ]
      }
    ]
  }
}
  • A group holds nested and type only: field, operator or value next to nested give a 422.
  • rest.limits.depths.filters sets how many nested levels a search accepts, 1 by default. A deeper group gives The search.filters.0.nested.0.nested field is prohibited., and nothing below it is validated.
  • Each nested list takes at most rest.limits.sizes.filters filters, like filters itself.

Relation paths

A dotted field filters on a field of a related resource. It keeps the rows that have at least one related row matching the filter.

POST /api/posts/search
{
  "search": {
    "filters": [
      {"field": "author.last_name", "value": "Lovelace"},
      {"field": "comments.body", "operator": "like", "value": "%thanks%"}
    ]
  }
}
  • The path is checked on the resource at its end: author.last_name needs a filterable last_name field on the resource of the author relation.
  • The perimeterQuery() of the resource of each relation of the path applies, each in its own group: a post matches only through an author that the perimeter of the authors keeps.
  • Each resource of the path needs viewAny, checked before validation. A denial answers 403.
  • A path goes through at most rest.limits.depths.includes relations, 3 by default: The search.filters.0.field field goes through more relations than the limit of 3.
  • A field with filterUsing() runs its callback at the end of a path too.

Pivot fields

A pivot segment reads a pivot field of a BelongsToMany, MorphToMany or MorphedByMany relation:

{"field": "tags.pivot.weight", "operator": ">=", "value": 5}
  • pivot is accepted only right after a many-to-many relation and right before the last segment.
  • The last segment must be a filterable pivot field of that relation: a writeOnly() or filterable(false) pivot field, or a selectable(false) one without filterable(), gives a 422.
  • The value is compared on the pivot table, morph pivot tables and the table of a pivot class included.

MorphTo paths

A path through a MorphTo relation with one target, such as commentable.title, reads the field on the target resource and only matches the rows whose morph type is the model of that resource. Rows of another type never match. Through a MorphTo with several targets, a dotted path gives a 422: use a relation group with types.

Relation groups

A filter that holds relation filters on the related rows of one relation, and applies all its filters to the same related row.

POST /api/authors/search
{
  "search": {
    "filters": [
      {
        "relation": "posts",
        "nested": [
          {"field": "status", "value": "published"},
          {"field": "featured", "value": true}
        ]
      },
      {"relation": "posts", "exists": false, "nested": [{"field": "status", "value": "draft"}], "type": "or"}
    ]
  }
}

The first group keeps the authors with one post that is both published and featured. Two dotted filters, posts.status and posts.featured, could match two different posts.

  • The keys are relation, exists, type and nested, and types on a MorphTo.
  • With exists true, the default, the group keeps the rows that have at least one related row matching every filter of nested. With exists false, it keeps the rows that have none.
  • Without nested, or with [], any related row counts: {"relation": "posts", "exists": false} keeps the authors without posts.
  • nested takes the fields of the related resource, with their operators and values, type, groups, relation paths and relation groups. A relation group inside nested needs rest.limits.depths.filters at 2 or more.
  • relation names one relation of the resource. A dotted path gives The selected search.filters.0.relation is invalid.: reach a deeper relation with a path in nested, such as {"relation": "posts", "nested": [{"field": "comments.body", "value": "Thanks"}]}.
  • exists is true or false. Another value, 1, 0 and null included, gives The search.filters.0.exists field must be true or false.
  • field, operator or value next to relation give The search.filters.0.field field is prohibited.
  • The perimeterQuery() of the related resource applies in its own group, and the related resource needs viewAny.
  • On a many-to-many relation, nested takes the fields of the related resource. A pivot field there gives a 422: filter it with a path from the resource that declares the relation, tags.pivot.weight.

MorphTo groups

On a MorphTo, the group takes types, one list of filters for each morph type:

{"relation": "labelable", "types": {"book": [{"field": "title", "value": "Dune"}], "shelf": []}}
  • The group keeps the rows whose target is a book titled Dune, or any shelf. An empty list means any target of that type.
  • Without types, any target of a declared type counts.
  • Each type applies its fields and its perimeterQuery(), and needs viewAny.
  • With several targets, nested gives The search.filters.0.nested field is prohibited: this relation has several types. Set it for each type, under types. With one target, nested works, but not together with types.
  • With exists false, the group keeps the rows of other types, of a type that is not a target, and without a type.

Custom filters

filterUsing() filters a field with a callback instead of its column. A virtual() or writeOnly() field becomes filterable with it.

use Illuminate\Contracts\Database\Eloquent\Builder;

Text::make('full_name')
    ->virtual()
    ->filterable(['=', 'like'])
    ->filterUsing(fn (Builder $query, string $operator, mixed $value) => $query
        ->where('first_name', $operator, $value)
        ->orWhere('last_name', $operator, $value)),
  • The callback receives the Eloquent query, the operator as a string and the value as sent. It can also be an object that implements Lomkit\Rest\Contracts\Filter.
  • Its conditions stay in their own group: an orWhere() in it does not leave the filter.
  • The other fields are compared on their column, qualified with the table of the model.

Fields that filters refuse

filters accepts the filterable fields only. These fields give The selected search.filters.0.field is invalid.:

  • an undeclared field;
  • a Json field;
  • a field declared filterable(false);
  • a virtual() or writeOnly() field without filterUsing();
  • a field declared selectable(false) without filterable().

Limits

LimitDefaultCounts
rest.limits.sizes.filters100The entries of filters, of each nested list, and of the filters of includes and aggregates
rest.limits.totals.filters500Every filter of one search, at every depth, in its includes and aggregates
rest.limits.totals.relations20The relation filters of one search: each dotted path and each relation group runs a subquery
rest.limits.sizes.values100The values of in, not in, between and not between
rest.limits.totals.patterns10The like, not like, ilike and not ilike filters of one search, at every depth, in its includes and aggregates
rest.limits.lengths.values1000The characters of a string value, and of each string of a list
rest.limits.lengths.patterns255The characters of a like, not like, ilike or not ilike value
rest.limits.depths.filters1The levels of nested

A list over its limit gives a 422 on its own key, and nothing under it is validated:

422 Unprocessable Content
{
  "message": "The search.filters field goes over the limit of 20 relation filters in one search.",
  "errors": {
    "search.filters": ["The search.filters field goes over the limit of 20 relation filters in one search."]
  }
}

Errors

MessageKey
The selected search.filters.0.field is invalid.A field that filters refuse
The selected search.filters.0.operator is invalid.An operator that the field does not accept
The search.filters.0.operator field is required.No operator on a field that does not accept =
The search.filters.0.value field must be an integer.A value of the wrong type, here on an Integer field
The search.filters.0.value field must contain 2 items.between without exactly two values
The search.filters.0.value field must have at least 1 items.in with an empty list
The search.filters.0.value field is prohibited.A value next to is null or is not null
The search.filters.0.value field must not be greater than 1000 characters.A string value over rest.limits.lengths.values
The search.filters field goes over the limit of 10 pattern filters in one search.More pattern filters than rest.limits.totals.patterns
The search.filters.0.operater field is prohibited.A key that a filter does not take
The search.filters.0.field field must be a string.A field that is not a string

Next to a text search on a search engine, filters follow the declaration of the engine instead, see Search engines.