Phoenix LiveView and SQLite Autocomplete

Image of a wacky database building an index.
Image by Annie Ruygt

This is a series using SQLite3 Full Text Search and Elixir. Fly.io is a great place to run Elixir applications! Check out how to get started!

In our last post we saw an example of how we could use the built-in Full Text Search capability, FTS5, of SQLite3 to create a search index and query it with Ecto. We also know it’s fast to query and especially so since it is in memory.

Let’s see how easy it is to show those results using Phoenix LiveView!

To begin we’ll need some data; I just so happen to have a SQLite database built indexing the Fly.io/docs. My schema is set up the same as the previous examples with a title, URL and body, I also include a levels or hierarchy listing to match the hierarchy in the fly.io/docs.

The design I am working towards is inspired by the existing Fly.io/docs search, and when we are done we should have something that looks like this:

First thing we should do when given a design like this is break it down into smaller components and try to work out the ultimate structure of what will become our code. I am using a tool called Excalidraw but pen and paper works too!

Wireframe diagram of a modal with search input and results list

Breaking this down:

  • A modal that will show up once we click the search dialog
  • A new search input, which should be automatically focused,
  • Search Results list with many result items.

We will scaffold out a live_component that does just that! Create the file lib/app_web/live/document_live/search_component.ex:

defmodule AppWeb.DocumentLive.SearchComponent do
  use AppWeb, :live_component

  @impl true
  def render(assigns) do
    ~H"""
    """
  end

  def search_input(assigns) do
    ~H"""
    """
  end

  def results(assigns) do
    ~H"""
    """
  end

  def result_item(assigns) do
    ~H"""
    """
  end

  def search_modal(assigns) do
    ~H"""
    """
  end

  @impl true
  def update(assigns, socket) do
    {:ok, socket}
  end
end

We have our main render function, our update function and the three components we highlighted above. Since this component will be stateful we need to render it in one of our live pages, in my case lib/app_web/live/document_live/index.html.heex:

<.live_component 
     module={SearchComponent}
     id="search-results"
     show={true}
     on_cancel={%JS{}} 
   />

Here, you would wire up show and on_cancel to an assigns or click event. In the video above I hooked it up to a click event on my fake search input. This is left as an exercise to the reader.

From here there are many paths one could take; personally, I prefer to have some real data loaded up into my assigns before I begin. Let us modify the update/2 to call to our Context function search_documents/1 with a default query.

  @impl true
  def update(assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign_new(:documents, search_documents("sqlite", []))
     |> assign_new(:query, "sqlite")
    }
  end

Here we apply all the default assigns that came from update, and we include documents and query.

Now when querying the SQLite3 FTS5 index it can be a little finicky and it will raise an exception if you send it something it can’t handle. We will handle that case in our search_documents function

  defp search_documents(query, default) when is_binary(query) do
    try do
      Content.search_documents(query)
    rescue 
      Exqlite.Error ->
        default
    end
  end
  defp search_documents(_, default), do: default

Pretty self-explanatory, if Exqlite doesn’t love our query and throws a parse error, we simply return the last known good results. This protects us from users trying out nefarious inputs and the user from SQLite’s finicky parser.

Normally in Elixir we have a “Let it fail!” attitude where errors result in the supervisor restarting your process, but in this case it would result in LiveView reloading the page and losing the user’s position. So lets protect the user from that.

Finally, we’ve got some search results data loaded up and can render the results! So let’s call our functions in our top level render function:

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.search_modal :if={@show} id="search-modal" show on_cancel={@on_cancel}>
        <.search_input value={@query} phx-target={@myself} phx-keyup="do-search" phx-debounce="200" />
        <.results docs={@documents} />
      </.search_modal>
    </div>
    """
  end


What we’re doing here is setting up our modal, conditionally showing it based on the @show assign, rendering our search_input and results. Thanks to our stubbed functions, this should render nothing at all! The beauty is, as we fill in the pieces, it will all start showing up on the page.

Starting with the modal, let’s not dive too deep into how it is set up because frankly it’s copied from the default core_components.ex and modified to work better for our use case.

  attr :id, :string, required: true
  attr :show, :boolean, default: false
  attr :on_cancel, JS, default: %JS{}
  slot :inner_block, required: true

  def search_modal(assigns) do
    ~H"""
    <div
      id={@id}
      phx-mounted={@show && show_modal(@id)}
      phx-remove={hide_modal(@id)}
      class="relative z-50 hidden"
    >
      <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
      <div
        class="fixed inset-0 overflow-y-auto"
        aria-labelledby={"#{@id}-title"}
        aria-describedby={"#{@id}-description"}
        role="dialog"
        aria-modal="true"
        tabindex="0"
      >
        <div class="flex min-h-full justify-center">
          <div class="w-full min-h-12 max-w-3xl p-2 sm:p-4 lg:py-6">
            <.focus_wrap
              id={"#{@id}-container"}
              phx-mounted={@show && show_modal(@id)}
              phx-window-keydown={hide_modal(@on_cancel, @id)}
              phx-key="escape"
              phx-click-away={hide_modal(@on_cancel, @id)}
              class="hidden relative rounded-2xl bg-white p-2 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition min-h-[30vh] max-h-[50vh] overflow-y-scroll"
            >
              <div id={"#{@id}-content"}>
                <%= render_slot(@inner_block) %>
              </div>
            </.focus_wrap>
          </div>
        </div>
      </div>
    </div>
    """
  end

We set up our attrs at the top, and render the modal. The major changes are:

  • removed the header, button areas and the close button
  • added spacing and shifted the whole thing up

This will accept an inner block and handle opening and closing the modal for us, and shares all the modal logic and transitions for doing so!

The next item in our modal is the search input but I like to see the modal in action so lets jump straight to rendering results.

  attr :docs, :list, required: true
  def search_results(assigns) do
    ~H"""
      <ul class="-mb-2 py-2 text-sm text-gray-800 flex space-y-2 flex-col" id="options" role="listbox">
        <li :if={@docs == []} id="option-none" role="option" tabindex="-1" class="cursor-default select-none rounded-md px-4 py-2 text-xl">
          No Results
        </li>

        <.link navigate={~p"/documents/#{doc.id}"} id={"doc-#{doc.id}"} :for={doc <- @docs}>
          <.result_item doc={doc} />
        </.link>
      </ul>
    """
  end

Here we declare we are expecting an list of @docs we setup a ul and conditionally render an li if we have no results, otherwise we iterate using the :for={doc <- @docs} helper and we call into result_item

  attr :doc, :map, required: true
  def result_item(assigns) do
    ~H"""
      <li class="cursor-default select-none rounded-md px-4 py-2 text-xl bg-zinc-100 hover:bg-zinc-800 hover:text-white hover:cursor-pointer flex flex-row space-x-2 items-center" id={"option-#{@doc.id}"} role="option" tabindex="-1" >
        <!-- svg of a document -->

        <div>
          <%= @doc.title %> 
          <div class="text-xs"><%= clean_levels(@doc.levels) %></div>
        </div>
      </li>
    """
  end

Which is almost unnecessary, it simply renders an li, with a title and our doc hierarchy. I left out the SVG heroicon for a document for brevity. If we check our webpage, what we should be seeing is something like this:

Which is great! The last step is to add an input and wire up some interactivity, so lets fill in the search_input function now

  attr :rest, :global
  def search_input(assigns) do
    ~H"""
      <div class="relative ">
        <!-- Heroicon name: mini/magnifying-glass -->

        <input {@rest} 
            type="text" 
            class="h-12 w-full border-none focus:ring-0 pl-11 pr-4 text-gray-800 placeholder-gray-400 sm:text-sm" 
            placeholder="Search the docs.." 
            role="combobox" 
            aria-expanded="false" 
            aria-controls="options">
      </div>
    """
  end

Which is almost a bare input! Once again, we left out the SVG for brevity, and the only thing we do here is assign all the attributes straight to the input. Let’s take a closer look at the call to search_input:

<.search_input 
    value={@query} 
    phx-target={@myself} 
    phx-keyup="do-search" 
    phx-debounce="200" 
    />


We set the value straight to our @query value. We also set the special phx-target attribute to @myself which tells Phoenix, “hey, this event should be routed to this component, not my parent.” Without this declaration, the key-up event would be dispatched to the caller of this component. Then we hook up the event to the do-search event, and we tell the front end to debounce this event.

Debounce here means the browser will only send an event every 200 milliseconds. The etymology for debounce is from electrical engineers working with mechanical switches. When a switch closes it doesn’t happen instantly, it has many points of partial contact as the two pieces of metal come close. This would cause a “ripple” of electricity through the wires, the act of debouncing was removing this “bouncing” ripple.

And the last step is that we need to remove our initialized code from update and to handle the input event:


  @impl true
  def mount(socket) do
    {:ok, socket, temporary_assigns: [docs: []]}
  end

  @impl true
  def update(assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign_new(:documents, [])
     |> assign_new(:search, "")
    }
  end

  @impl true
  def handle_event("do-search", %{"value" => value}, socket) do
    {:noreply, 
      socket
      |> assign(:search, value)
      |> assign(:documents, search_documents(value, socket.assigns.documents))
    }
  end

And finally we are there! If you want to see it once more, scroll up! I did add one small optimization and that was adding a mount function and declaring @docs to be a temporary_assign, this simply tells LiveView to not keep all the @docs in memory after every change, render the results then toss the data.

Notice when you have partially typed words the results can kinda flicker. This is the result of how the SQLite3 FTS5 indexes your text. It indexes based on tokens which are whole words separated by spaces, it does not do partial word matching. For example, if the word sqlite with spaces around it, is not in a document it won’t return any results. The same goes, if we type sq it will look for the token sq and not match sqlite.

If you want partial word matches don’t dismay! You simply need to use the Experimental Trigram Tokenizer! which can handle partial matching. I won’t be going into details in this post but follow that link and you can set it up for your usecases!

One thing that is immediately clear to me is that results are near instant! And that is because SQLite lives right in memory next to your application! We have no round trip to the database, minimal encoding and decoding and zero chance of dropping packets. And thanks to LiveView the user facing implementation is frankly boring!

This is what gets me so excited about SQLite, LiveView and Fly.io. If you use Fly.io’s global network you can deploy this to wherever your users are. You don’t need to use some kind of lambda running on wasm, calling propreitary databases and propreitary API’s, this is normal code that you own. And as always with LiveView there is no JavaScript to think about, just elixir code running on the server.

Next time we discuss some ways you could Architect your search, trying out the distributed SQLite Database LiteFS, and ways to keep your data fresh!

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!