An ilike filter matches literal substrings, so searching for "run" also
matches "brunch", while searching for "running" does not match "run". It also
has no notion of how well a row matches. PostgreSQL full text search matches
word stems, so "run" and "running" find each other, "brunch" is unrelated, and
the results can be ranked.
In Flop, full text search can be implemented with a custom field, and the rank with an alias field. This recipe is specific to PostgreSQL.
Search column
Store the document in a tsvector column and index it. A generated column keeps
it in sync with the source columns, so nothing in your application has to
remember to update it.
defmodule MyApp.Repo.Migrations.AddPetSearchColumn do
use Ecto.Migration
def up do
execute """
ALTER TABLE pets
ADD COLUMN searchable tsvector
GENERATED ALWAYS AS (
to_tsvector(
'english',
coalesce(name, '') || ' ' || coalesce(description, '')
)
) STORED
"""
execute "CREATE INDEX pets_searchable_idx ON pets USING GIN (searchable)"
end
def down do
execute "DROP INDEX pets_searchable_idx"
execute "ALTER TABLE pets DROP COLUMN searchable"
end
endConcatenating with || yields NULL if any operand is NULL, which would
leave the whole document empty for a pet without a description. The coalesce
calls prevent this.
Add the column to the schema, so that you can refer to it in a query.
schema "pets" do
field :name, :string
field :description, :string
field :searchable, :string, load_in_query: false
endEcto has no tsvector type. The column is only ever used in the WHERE clause,
and load_in_query: false keeps it out of the select clause.
Filter
The search is a custom field, because @@ is not one of Flop's operators.
defmodule MyApp.Filters do
import Ecto.Query
def search(query, %Flop.Filter{value: value}, _opts) do
where(
query,
[p],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
p.searchable,
^value
)
)
end
end@derive {Flop.Schema,
filterable: [:search],
sortable: [:name],
adapter_opts: [
custom_fields: [
search: [
filter: {MyApp.Filters, :search, []},
ecto_type: :string,
operators: [:like]
]
]
]}The function needs no guard for an empty search box. Flop.validate/2 casts a
blank string to nil, and Flop skips filters with a nil value, so the
function is only called with something to search for.
Use websearch_to_tsquery rather than to_tsquery. It reads what a user types
into a search box, including quoted phrases, or and a leading -, and it
never raises. to_tsquery expects operator syntax and raises a syntax error on
input like pet &. Use plainto_tsquery if you do not want users to have
operators at all.
The filter function ignores the operator. We set operators: [:like] to avoid
offering operators that are not supported.
Accents
A search for "cafe" does not find "Café", because the two are different lexemes.
The unaccent extension removes the diacritics.
CREATE EXTENSION unaccent;Adding it to the generated column and to the index is where it gets in the way, because both of them fail:
ERROR 42P17 (invalid_object_definition) generation expression is not immutable
ERROR 42P17 (invalid_object_definition) functions in index expression
must be marked IMMUTABLEA generated column and an index both store the result of the expression, so
PostgreSQL only accepts functions it can rely on to return the same result for
the same argument forever, which it calls IMMUTABLE. unaccent/1 is one step
weaker, STABLE: it looks up the default dictionary at run time, and that
dictionary can be changed.
The two-argument form takes the dictionary as an argument instead of looking it up, so a wrapper around it can be declared immutable.
CREATE FUNCTION immutable_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
AS $$ SELECT unaccent('unaccent', $1) $$;Use it on both sides. In the generated column:
to_tsvector('simple', immutable_unaccent(coalesce(name, '')))And on the search term:
fragment(
"? @@ websearch_to_tsquery('simple', immutable_unaccent(?))",
p.searchable,
^value
)The 'simple' configuration is deliberate here. The language configurations
stem words, which is usually what you want for a description, but they also
expect their own language's spelling, so combine them with unaccent only if
that suits your data.
Ranking
ts_rank scores a row against the query. Sorting by it needs the score in the
select clause under a name. You can use Ecto.Query.API.selected_as/2 and an
alias field for this.
@derive {Flop.Schema,
filterable: [:search],
sortable: [:name, :rank],
adapter_opts: [
alias_fields: [:rank],
custom_fields: [
search: [
filter: {MyApp.Filters, :search, []},
ecto_type: :string,
operators: [:like]
]
]
]}ts_rank takes the search term as an argument, and the term arrives as a filter
parameter, so the select clause can only be built once the parameters are
validated. That is why we use Flop.validate/2 and Flop.run/3 rather than
Flop.validate_and_run/3 here.
def list_pets(params) do
opts = [for: Pet]
with {:ok, flop} <- Flop.validate(params, opts) do
flop.filters
|> Flop.Filter.get_value(:search)
|> ranked_query()
|> Flop.run(flop, opts)
end
end
defp ranked_query(term) when is_binary(term) do
from p in Pet,
select_merge: %{
rank:
selected_as(
fragment(
"ts_rank(?, websearch_to_tsquery('english', ?))",
p.searchable,
^term
),
:rank
)
}
end
defp ranked_query(_) do
from p in Pet, select_merge: %{rank: selected_as(fragment("0.0"), :rank)}
endThe second clause matters: a client can order by rank without a search term,
and PostgreSQL rejects an ORDER BY on a name the select clause does not
define.
Ranking has two limits that come from the alias field:
- An alias field cannot be filterable, since PostgreSQL does not allow a select
alias in a
WHEREclause. - Cursor pagination cannot use an alias field as cursor for the same reason.
Flop.validate/2returns a validation error for that combination, so use offset or page based pagination when you sort by rank.
Complete example
defmodule MyApp.Pet do
use Ecto.Schema
@derive {Flop.Schema,
filterable: [:search],
sortable: [:name, :rank],
default_order: %{
order_by: [:name],
order_directions: [:asc]
},
pagination_types: [:page, :offset],
adapter_opts: [
alias_fields: [:rank],
custom_fields: [
search: [
filter: {MyApp.Filters, :search, []},
ecto_type: :string,
operators: [:like]
]
]
]}
schema "pets" do
field :name, :string
field :description, :string
field :searchable, :string, load_in_query: false
field :rank, :float, virtual: true
end
end
defmodule MyApp.Filters do
import Ecto.Query
def search(query, %Flop.Filter{value: value}, _opts) do
where(
query,
[p],
fragment(
"? @@ websearch_to_tsquery('english', ?)",
p.searchable,
^value
)
)
end
end
defmodule MyApp.Pets do
import Ecto.Query
alias MyApp.Pet
def list_pets(params) do
opts = [for: Pet]
with {:ok, flop} <- Flop.validate(params, opts) do
flop.filters
|> Flop.Filter.get_value(:search)
|> ranked_query()
|> Flop.run(flop, opts)
end
end
defp ranked_query(term) when is_binary(term) do
from p in Pet,
select_merge: %{
rank:
selected_as(
fragment(
"ts_rank(?, websearch_to_tsquery('english', ?))",
p.searchable,
^term
),
:rank
)
}
end
defp ranked_query(_) do
from p in Pet, select_merge: %{rank: selected_as(fragment("0.0"), :rank)}
end
end