Tutorial

Fuzzy find with Ecto in Phoenix LiveView

search ecto liveview fuzzyseach

Fuzzy find is both a simple and a complex thing. Even though though its simple to implement, its hard to get right from a UX perspective. Luckily, I can help you get going with the technological implementation.

Postgres has some build in functionality for fuzzy search. First there is a extension called pg_trgm.

A trigram is a group of three consecutive characters taken from a string. So, by measuring the similarity of trigrams between two strings, it is possible to estimate how similar they are on a scale between 0 and 1. This allows for fuzzy matching, by setting a similarity threshold above which strings are considered to match.

Another built in function for comparing strings is the Levenshtein function. This function calculates the Levenshtein distance between two strings. That is the number of single character deletions, insertions, or substitutions required to transform one string into the other.

So, the starting point for this is a view with a list of products. I would like to add a search or filter with fuzzy functionality.

STEP 1 - Install the needed extensions

I need to create a migration where I activate the needed extensions.

defmodule ShopTest.Repo.Migrations.EnableFuzzyExtensions do
  use Ecto.Migration

  def up do
    execute "CREATE EXTENSION pg_trgm"
    execute "CREATE EXTENSION fuzzystrmatch"
  end

  def down do
    execute "DROP EXTENSION fuzzystrmatch"
    execute "DROP EXTENSION pg_trgm"
  end
end

STEP 2 - Building the search query

A good search function is a science in itself and it depends on use case, dataset and user base. However, In this example, I go about it in this way:

  • I want the starting characters to match. So if I type an "A", I only want to select phrases that starts with "A". For this I extract the first character and use it in an ILIKE.
  • Next, I only take search results where thare are some similarity. So SIMILARITY between the strings are about 0.
  • Last, I order the strings according to LEVENSHTEIN function.

One thing to note here is that the search result will be more correct, the more a user types in the search box.

So, in the products context, I add this serach function:

def search_products(search_phrase) do
  start_character = String.slice(search_phrase, 0..1)

  from(
    p in Product,
    where: ilike(p.name, ^"#{start_character}%"),
    where: fragment("SIMILARITY(?, ?) > 0",  p.name, ^search_phrase),
    order_by: fragment("LEVENSHTEIN(?, ?)", p.name, ^search_phrase)
  )
  |> Repo.all()
end

Next step will be to add the actual interface to the search

STEP 3 - Add search to LiveView

As I mentioned above, I already have a products LiveView. So I will start with adding the search form. So, in the index template on the correct spot, I add:

# lib/shop_test_web/live/product_live/index.html.leex

<%= f = form_for @changeset, "#", phx_change: "search", as: "search" %>
  <%= label f, :search_phrase, class: "tag-label" do %>
    <div class="tag-icon">
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
      <%= text_input f, :search_phrase, class: "tag-input",  phx_debounce: 500, placeholder: "Search product" %>
    </div>
  <% end %>
</form>

NOTE that I have added a debounce there on 500ms.

As soon as I type something I send an event to the ProductLive.Index module.

# lib/shop_test_web/live/product_live/index.ex

@impl true
def handle_event("search", %{"search" => search}, socket) do
  search
  |> search_changeset()
  |> case do
    %{valid?: true, changes: %{search_phrase: search_phrase}} ->
      {:noreply, assign(socket, :products, Products.search_products(search_phrase))}
    _ ->
      {:noreply, socket}
  end
end

Note that I have a changeset here. I want to have a schema less changeset. That makes it possible to add some validations to the actual search phrase before actually putting it in the query.

# lib/shop_test_web/live/product_live/index.ex

@types %{search_phrase: :string}

defp search_changeset(attrs \\ %{}) do
  cast(
    {%{}, @types},
    attrs,
    [:search_phrase]
  )
  |> validate_required([:search_phrase])
  |> update_change(:search_phrase, &String.trim/1)
  |> validate_length(:search_phrase, min: 2)
  |> validate_format(:search_phrase, ~r/[A-Za-z0-9\ ]/)
end

One more thing I want to add, is to reset the list if the search phrase changes to an empty string.

# lib/shop_test_web/live/product_live/index.ex

@impl true
def handle_event("search", %{"search" => %{"search_phrase" => search_phrase}}, socket) when search_phrase == "" do
  {:noreply, assign(socket, :products, list_products())}
end

FINAL RESULT

Related Tutorials

Published 15 Feb - 2020
Updated 01 May - 2020

Create ghost loading cards in Phoenix LiveView

Unless you already didn't know, when a LieView component is mounted on a page, it runs the mount/2 function twice. One when the page is rendered fro..

Published 02 Sep - 2020

Table sorting with Ecto and LiveView

A very common or even mandatory feature in e-commerce stores is the ability to sort a list of products by attributes. This is easy enough and a good..