Search engines
ScoutEngine runs the text search of a resource on the search engine of its Laravel Scout model. The engine matches the text, applies the filters and the sorts, pages the results and counts them. The package then loads the rows of the page in one SQL query, with the includes, aggregates, selects and gates of the search.
use Lomkit\Rest\Fields\Integer;
use Lomkit\Rest\Fields\Number;
use Lomkit\Rest\Text\Engine\EngineQuery;
use Lomkit\Rest\Text\ScoutEngine;
use Lomkit\Rest\Text\TextSearch;
public function text(): ?TextSearch
{
return ScoutEngine::make()
->filterable([Integer::make('category_id'), Number::make('price'), 'status'])
->sortable(['price'])
->perimeter(
fn (EngineQuery $query) => $query->where('team_id', '=', $this->request()->user()->team_id),
[Integer::make('team_id')],
);
}
{
"search": {
"text": {"value": "desk lamp"},
"filters": [{"field": "category_id", "operator": "in", "value": [1, 2]}],
"sorts": [{"field": "price"}],
"includes": [{"relation": "category"}]
}
}
A blank text, or a text without a letter or a digit, runs the normal SQL search, with its whole grammar.
Declaration
- The model uses
Laravel\Scout\Searchable, and its engine is a Scout engine or a class that extends one:collection,database,meilisearch,typesense,algolia,turbopufferornull. Another engine is refused, because the package cannot know which filters and sorts it runs. filterable()lists the index attributes that clients may filter on: fields, such asInteger::make('category_id'), or the names of fields of the resource, such as'status', which take the type and the operators of that field.- An attribute is an
ID,Integer,Number,Boolean,TextorEnumfield, withoutfilterUsing()orsortUsing().Date,DateTime,Jsonand app types that extend a field type are refused: index a date as a timestamp and declare it as anInteger. - A name starts with a letter or
_and holds only letters, digits and_.__soft_deletedis reserved. - A name that points to a field declared
selectable(false)needs that field to be declaredfilterable(). sortable()names thefilterable()attributes that clients may sort on.searchable()does not apply toScoutEngine.- The declaration is checked when the resource is first searched in a request. A missing perimeter, a model without
Searchable, another engine, a refused type, a refused name, an attribute declared twice or a sort on an attribute that is not filterable throwsInvalidTextSearchException, which names the resource.
The index perimeter
The index perimeter is required: declare perimeter() or withoutPerimeter(), not both.
- The closure of
perimeter()receives theEngineQueryof each search and reads the request with$this->request(). Its conditions narrow every search of the resource, its total and its pages, and clients cannot widen them. - Its second argument lists the attributes that only the perimeter uses: clients cannot filter or sort on them.
- The rows of a page are loaded by key without
perimeterQuery(): the index and its perimeter decide the rows. Declare inperimeter()every condition that keeps rows from some users, on attributes that the index holds. - Keep these attributes in sync with the database: set
scout.after_committotrue, and leavescout.queueoff for a model whose perimeter keeps rows from some users, so that a change reaches the index before the next search. search.textmatches the attributes that the index searches (with thedatabaseandcollectionengines, every key oftoSearchableArray()). Index only attributes that every user who can list the resource may read.- Use
withoutPerimeter()only when every such user may see every indexed row.
Filters and sorts
Next to a filled text, the grammar follows the declaration of the engine:
filterstake thefilterable()attributes, combined withand. Theortype,nested, relation paths and relation groups give a 422.=,!=,inandnot inwork on every attribute;>,>=,<and<=onIntegerandNumberattributes, among the operators of the field.inandnot inneed a list of at least one value.nullgives a 422. A number that is not finite gives a 422.- Every engine receives filter values as literals. A string that holds a backtick or a colon followed by
(or[ (, ends with*, is wrapped in double quotes, or is a number written with+givesThe search.filters.0.value field holds a value the search engine cannot compare. sortstake thesortable()attributes.@relevancekeeps the order of the engine: it is accepted alone, withoutdirectionor withdesc.ascgives a 422 undersearch.sorts.0.direction, and@relevancenext to another sort gives a 422.text.modeacceptswordsonly. The engine receives the letters and digits of the text, separated by single spaces: the query syntax of the engine has no effect.scopesgives a 422.instructionstakes the instructions that implementAppliesToEngine, see below.trashedneeds a model that usesSoftDeletesandscout.soft_deleteset totrue, otherwise it gives a 422 next to a text.includes,aggregates,selectsandgatesare validated and applied as in a SQL search, on the rows of the page. Each include and aggregate applies theperimeterQuery()of its own resource.- The
update,delete,restoreandforceDeletegates of a page row answerfalsewhenperimeterQuery()leaves the row out.
Pages and totals
- The engine pages the results with
limitandpage.totalandlast_pageare the engine's, and the rows keep the order of the engine. - The rows of the page load in one SQL query, by their scout key (
getScoutKeyName()), with the global scopes and the soft deletes of the model. A row that the index returns and that the database no longer holds, or that a global scope leaves out, is left out ofdata, andtotalstill counts it. - Keys follow
getScoutKeyType():intorintegergives integer keys, any other type string keys. A model whose scout key is not its primary key declaresgetScoutKeyType(). - The package builds the Scout query itself: an override of the model's
search(),getScoutModelsByIds()orqueryScoutModelsByIds()is not used.Resource::paginate()serves the SQL searches only. $maxTotaldoes not apply: the page keeps the total of the engine and leavestotal_cappedout.
Engines
| Engine | Notes |
|---|---|
collection, database | Every operator and sort, exact totals. On PostgreSQL, Scout's database engine compares every key of toSearchableArray() with ilike: return text columns only there |
meilisearch | Exact totals up to pagination.maxTotalHits (1000 by default). Declare the filterable() and perimeter attributes in filterableAttributes, and the sortable() ones in sortableAttributes, for example with scout.meilisearch.index-settings and php artisan scout:sync-index-settings |
typesense | Exact totals. The model needs query_by in scout.typesense.model-settings.<model class>.search-parameters, or in typesenseSearchParameters(). A page holds at most 250 rows, and a search takes at most 3 sorts |
algolia | Exact totals, no sorts: sortable() throws, and @relevance alone is accepted |
turbopuffer | No sorts. A page must end within the first 10,000 results. total counts the documents that match the filters, not the text |
null | An empty page and a total of 0, without SQL query |
Meilisearch and Typesense compare filter strings without regard to case: prefer Integer, ID, Boolean or Enum perimeter attributes. On Meilisearch, Typesense, Algolia and Turbopuffer, a row that a global scope of the model leaves out can be in the index: keep it out with shouldBeSearchable(), or add the condition to the perimeter.
Engine queries
The perimeter and the instructions receive an EngineQuery:
where($attribute, $operator, $value)takes=,!=,<,>,<=or>=.whereIn($attribute, $values)andwhereNotIn($attribute, $values)take a list.- The attribute must be declared in
filterable()or in the perimeter, with this operator. The value is converted to the type of the attribute, a backed enum to its value. Anything else throwsInvalidEngineQueryException, aLogicException. - The conditions add up and never replace each other: two
whereIn()on one attribute keep the values that both lists hold. An emptywhereIn()returns an empty page without calling the engine. orderBy($attribute, $direction)sorts on asortable()attribute.options([...])adds options for the engine. An option set again with another value throws, and so do the options that the package sets itself.
Instructions
An instruction runs next to an engine text when it implements AppliesToEngine:
use Illuminate\Database\Eloquent\Builder;
use Lomkit\Rest\Fields\Number;
use Lomkit\Rest\Instructions\AppliesToEngine;
use Lomkit\Rest\Instructions\Instruction;
use Lomkit\Rest\Instructions\InstructionFields;
use Lomkit\Rest\Text\Engine\EngineQuery;
class PricedUpTo extends Instruction implements AppliesToEngine
{
public function fields(): array
{
return [Number::make('max')->rules('required')];
}
public function handle(InstructionFields $fields, Builder $query): void
{
$query->where('price', '<=', $fields->max);
}
public function handleEngine(InstructionFields $fields, EngineQuery $query): void
{
$query->where('price', '<=', $fields->max);
}
}
- Another instruction gives a 422 next to an engine text.
- An
AppliesToEngineinstruction that does not overridehandle()only applies to engine text searches: it gives a 422 in a SQL search, such as a search without text, an include or the search of aContainsresource. - In the details, each instruction says under
textwhether it applies to a search with text on this resource.
Actions
An action whose search has an engine text reads every key from the engine first, in pages of chunkCount, then loads each chunk in SQL by key, before it runs, is queued or starts a batch.
- A search that matches more than
maxResourcesresources givesThis action can be run on at most 1000 resources; narrow the search.undersearch. Withsearch.limit, the action acts on the firstlimitresults. - Without
search.limit, the action readspagination.maxTotalHitson Meilisearch orpaginationLimitedToon Algolia: the application key must be allowed to read the settings of the index. Set these limits above themaxResourcesof the actions. - When the results change while the action reads them, it gives
The search results changed while the action was resolving them; run the action again.
See Actions.
Text search
Declare the text search of a resource with LIKE or the full-text index of the database, pick a mode per request and sort by relevance.
Searching outside HTTP
Replay a client search payload from a job, a command or an export with Rest::search(), with the validation, perimeter and policies of the endpoint.