docs/integration-patterns.md

# Integration Patterns

This guide covers common patterns for integrating TantivyEx into your Elixir applications, from simple GenServer wrappers to complex Phoenix LiveView implementations.

## GenServer-based Search Service

A GenServer provides a stateful wrapper around your search index with automatic management and background operations:

```elixir
defmodule MyApp.SearchService do
  use GenServer
  require Logger
  alias TantivyEx.{Index, IndexWriter, Searcher}

  @index_path "/var/lib/myapp/search_index"

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # Public API
  def search(query, limit \\ 10) do
    GenServer.call(__MODULE__, {:search, query, limit}, 10_000)
  end

  def add_document(document) do
    GenServer.call(__MODULE__, {:add_document, document})
  end

  def add_documents(documents) when is_list(documents) do
    GenServer.call(__MODULE__, {:add_documents, documents})
  end

  def commit do
    GenServer.call(__MODULE__, :commit)
  end

  def reload_index do
    GenServer.call(__MODULE__, :reload_index)
  end

  def get_stats do
    GenServer.call(__MODULE__, :get_stats)
  end

  # GenServer Callbacks
  def init(_opts) do
    Logger.info("Starting SearchService")

    case load_index() do
      {:ok, index} ->
        schedule_auto_commit()
        state = %{
          index: index,
          uncommitted_changes: false,
          last_commit: DateTime.utc_now(),
          document_count: 0
        }
        {:ok, state}

      {:error, reason} ->
        Logger.error("Failed to load search index: #{inspect(reason)}")
        {:stop, reason}
    end
  end

  def handle_call({:search, query, limit}, _from, state) do
    result = perform_search(state.index, query, limit)
    {:reply, result, state}
  end

  def handle_call({:add_document, document}, _from, state) do
    case add_single_document(state.index, document) do
      :ok ->
        new_state = %{
          state |
          uncommitted_changes: true,
          document_count: state.document_count + 1
        }
        {:reply, :ok, new_state}

      error ->
        {:reply, error, state}
    end
  end

  def handle_call({:add_documents, documents}, _from, state) do
    case add_multiple_documents(state.index, documents) do
      :ok ->
        new_state = %{
          state |
          uncommitted_changes: true,
          document_count: state.document_count + length(documents)
        }
        {:reply, :ok, new_state}

      error ->
        {:reply, error, state}
    end
  end

  def handle_call(:commit, _from, state) do
    case commit_changes(state.index) do
      :ok ->
        new_state = %{
          state |
          uncommitted_changes: false,
          last_commit: DateTime.utc_now()
        }
        {:reply, :ok, new_state}

      error ->
        {:reply, error, state}
    end
  end

  def handle_call(:reload_index, _from, state) do
    case load_index() do
      {:ok, new_index} ->
        new_state = %{
          state |
          index: new_index,
          uncommitted_changes: false,
          last_commit: DateTime.utc_now()
        }
        {:reply, :ok, new_state}

      {:error, reason} ->
        {:reply, {:error, reason}, state}
    end
  end

  def handle_call(:get_stats, _from, state) do
    stats = %{
      uncommitted_changes: state.uncommitted_changes,
      last_commit: state.last_commit,
      document_count: state.document_count,
      uptime: calculate_uptime()
    }
    {:reply, stats, state}
  end

  # Auto-commit every 30 seconds if there are uncommitted changes
  def handle_info(:auto_commit, %{uncommitted_changes: true} = state) do
    Logger.debug("Auto-committing search index")
    commit_changes(state.index)
    schedule_auto_commit()

    new_state = %{
      state |
      uncommitted_changes: false,
      last_commit: DateTime.utc_now()
    }
    {:noreply, new_state}
  end

  def handle_info(:auto_commit, state) do
    schedule_auto_commit()
    {:noreply, state}
  end

  # Private functions
  defp load_index do
    Logger.info("Opening or creating search index at #{@index_path}")
    {:ok, schema} = create_schema()
    Index.open_or_create(@index_path, schema)
  end

  defp create_schema do
    alias TantivyEx.Schema

    schema = Schema.new()
    schema = Schema.add_text_field(schema, "title", :text_stored)
    schema = Schema.add_text_field(schema, "content", :text)
    schema = Schema.add_text_field(schema, "author", :text_stored)
    schema = Schema.add_u64_field(schema, "timestamp", :fast_stored)
    schema = Schema.add_facet_field(schema, "category", :facet)
    {:ok, schema}
  end

  defp perform_search(index, query, limit) do
    {:ok, searcher} = Searcher.new(index)
    case Searcher.search(searcher, query, limit) do
      {:ok, results} ->
        Logger.debug("Search for '#{query}' returned #{length(results)} results")
        {:ok, results}

      {:error, reason} ->
        Logger.warning("Search failed for query '#{query}': #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp add_single_document(index, document) do
    {:ok, writer} = IndexWriter.new(index)
    IndexWriter.add_document(writer, document)
  end

  defp add_multiple_documents(index, documents) do
    {:ok, writer} = IndexWriter.new(index)

    Enum.reduce_while(documents, :ok, fn doc, _acc ->
      case IndexWriter.add_document(writer, doc) do
        :ok -> {:cont, :ok}
        error -> {:halt, error}
      end
    end)
  end

  defp commit_changes(index) do
    {:ok, writer} = IndexWriter.new(index)
    IndexWriter.commit(writer)
  end

  defp schedule_auto_commit do
    Process.send_after(self(), :auto_commit, 30_000)
  end

  defp calculate_uptime do
    # Simple uptime calculation - you might want to store start_time in state
    DateTime.utc_now()
  end
end
```

## Phoenix Integration

### Search Controller

```elixir
defmodule MyAppWeb.SearchController do
  use MyAppWeb, :controller
  alias MyApp.SearchService
  require Logger

  plug :validate_search_params when action in [:search]

  def search(conn, %{"q" => query} = params) do
    limit = parse_limit(params)
    page = parse_page(params)

    case SearchService.search(query, limit * page) do
      {:ok, all_results} ->
        {results, pagination} = paginate_results(all_results, page, limit)

        render(conn, :search, %{
          query: query,
          results: results,
          pagination: pagination,
          total: length(all_results)
        })

      {:error, reason} ->
        Logger.warning("Search failed: #{inspect(reason)}")

        conn
        |> put_flash(:error, "Search failed. Please try again.")
        |> render(:search, %{
          query: query,
          results: [],
          pagination: %{},
          total: 0
        })
    end
  end

  def suggest(conn, %{"q" => query}) when byte_size(query) >= 2 do
    # Autocomplete suggestions
    case SearchService.search("#{query}*", 5) do
      {:ok, results} ->
        suggestions =
          results
          |> Enum.map(&extract_suggestion/1)
          |> Enum.uniq()
          |> Enum.take(5)

        json(conn, %{suggestions: suggestions})

      {:error, _} ->
        json(conn, %{suggestions: []})
    end
  end

  def suggest(conn, _params) do
    json(conn, %{suggestions: []})
  end

  def advanced_search(conn, params) do
    query = build_advanced_query(params)

    case SearchService.search(query, 50) do
      {:ok, results} ->
        render(conn, :advanced_search, %{
          results: results,
          params: params,
          query: query
        })

      {:error, reason} ->
        conn
        |> put_flash(:error, "Advanced search failed: #{reason}")
        |> render(:advanced_search, %{results: [], params: params, query: ""})
    end
  end

  # Private functions
  defp validate_search_params(conn, _opts) do
    case get_in(conn.params, ["q"]) do
      query when is_binary(query) and byte_size(query) > 0 ->
        if byte_size(query) <= 1000 do
          conn
        else
          conn
          |> put_flash(:error, "Search query too long")
          |> redirect(to: ~p"/search")
          |> halt()
        end

      _ ->
        conn
        |> put_flash(:error, "Please enter a search query")
        |> redirect(to: ~p"/search")
        |> halt()
    end
  end

  defp parse_limit(%{"limit" => limit}) when is_binary(limit) do
    case Integer.parse(limit) do
      {num, _} when num > 0 and num <= 100 -> num
      _ -> 10
    end
  end
  defp parse_limit(_), do: 10

  defp parse_page(%{"page" => page}) when is_binary(page) do
    case Integer.parse(page) do
      {num, _} when num > 0 -> num
      _ -> 1
    end
  end
  defp parse_page(_), do: 1

  defp paginate_results(results, page, limit) do
    start_index = (page - 1) * limit
    page_results = Enum.slice(results, start_index, limit)

    pagination = %{
      current_page: page,
      per_page: limit,
      total_results: length(results),
      total_pages: div(length(results) + limit - 1, limit)
    }

    {page_results, pagination}
  end

  defp extract_suggestion(result) do
    # Extract meaningful suggestion text
    Map.get(result, "title", "")
  end

  defp build_advanced_query(params) do
    query_parts = []

    query_parts =
      if title = params["title"], do: ["title:(#{title})" | query_parts], else: query_parts

    query_parts =
      if author = params["author"], do: ["author:(#{author})" | query_parts], else: query_parts

    query_parts =
      if category = params["category"], do: ["category:\"#{category}\"" | query_parts], else: query_parts

    query_parts =
      if date_from = params["date_from"] do
        timestamp = parse_date_to_timestamp(date_from)
        ["timestamp:[#{timestamp} TO *]" | query_parts]
      else
        query_parts
      end

    case query_parts do
      [] -> "*"
      parts -> Enum.join(parts, " AND ")
    end
  end

  defp parse_date_to_timestamp(date_string) do
    case Date.from_iso8601(date_string) do
      {:ok, date} ->
        date
        |> DateTime.new!(~T[00:00:00])
        |> DateTime.to_unix()

      _ -> 0
    end
  end
end
```

### LiveView Integration

```elixir
defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view
  alias MyApp.SearchService
  require Logger

  @debounce_timeout 300

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:query, "")
      |> assign(:results, [])
      |> assign(:loading, false)
      |> assign(:suggestions, [])
      |> assign(:show_suggestions, false)
      |> assign(:search_stats, %{})

    {:ok, socket}
  end

  def handle_params(%{"q" => query}, _uri, socket) when query != "" do
    send(self(), {:perform_search, query})

    socket =
      socket
      |> assign(:query, query)
      |> assign(:loading, true)
      |> assign(:show_suggestions, false)

    {:noreply, socket}
  end

  def handle_params(_params, _uri, socket) do
    {:noreply, assign(socket, :query, "")}
  end

  def handle_event("search_input", %{"query" => query}, socket) do
    # Cancel any pending search
    if socket.assigns[:search_timer] do
      Process.cancel_timer(socket.assigns.search_timer)
    end

    socket =
      socket
      |> assign(:query, query)
      |> assign(:show_suggestions, String.length(query) >= 2)

    # Set up debounced search
    if String.length(query) >= 2 do
      timer = Process.send_after(self(), {:get_suggestions, query}, @debounce_timeout)
      socket = assign(socket, :search_timer, timer)
      {:noreply, socket}
    else
      socket =
        socket
        |> assign(:suggestions, [])
        |> assign(:show_suggestions, false)
      {:noreply, socket}
    end
  end

  def handle_event("search_submit", %{"query" => query}, socket) do
    if String.trim(query) != "" do
      path = ~p"/search?#{%{q: query}}"
      {:noreply, push_navigate(socket, to: path)}
    else
      {:noreply, socket}
    end
  end

  def handle_event("select_suggestion", %{"suggestion" => suggestion}, socket) do
    path = ~p"/search?#{%{q: suggestion}}"
    {:noreply, push_navigate(socket, to: path)}
  end

  def handle_event("clear_search", _params, socket) do
    socket =
      socket
      |> assign(:query, "")
      |> assign(:results, [])
      |> assign(:suggestions, [])
      |> assign(:show_suggestions, false)
      |> assign(:search_stats, %{})

    {:noreply, push_navigate(socket, to: ~p"/search")}
  end

  def handle_info({:get_suggestions, query}, socket) do
    suggestions = get_search_suggestions(query)

    socket =
      socket
      |> assign(:suggestions, suggestions)
      |> assign(:show_suggestions, length(suggestions) > 0)

    {:noreply, socket}
  end

  def handle_info({:perform_search, query}, socket) do
    start_time = System.monotonic_time(:millisecond)

    results =
      case SearchService.search(query, 20) do
        {:ok, results} -> results
        {:error, reason} ->
          Logger.warning("Search failed: #{inspect(reason)}")
          []
      end

    end_time = System.monotonic_time(:millisecond)
    search_time = end_time - start_time

    stats = %{
      query: query,
      result_count: length(results),
      search_time_ms: search_time
    }

    socket =
      socket
      |> assign(:results, results)
      |> assign(:loading, false)
      |> assign(:search_stats, stats)
      |> assign(:show_suggestions, false)

    {:noreply, socket}
  end

  def render(assigns) do
    ~H"""
    <div class="search-container max-w-4xl mx-auto p-6">
      <div class="search-header mb-8">
        <h1 class="text-3xl font-bold text-gray-900 mb-4">Search</h1>

        <div class="relative">
          <form phx-submit="search_submit" class="relative">
            <input
              type="text"
              name="query"
              value={@query}
              placeholder="Search for articles, tutorials, and more..."
              class="w-full px-4 py-3 text-lg border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              phx-keyup="search_input"
              phx-debounce="100"
              autocomplete="off"
            />

            <button
              type="submit"
              class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
            >
              <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
              </svg>
            </button>

            <%= if @query != "" do %>
              <button
                type="button"
                phx-click="clear_search"
                class="absolute right-10 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
              >
                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
                </svg>
              </button>
            <% end %>
          </form>

          <!-- Suggestions dropdown -->
          <%= if @show_suggestions and length(@suggestions) > 0 do %>
            <div class="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg">
              <%= for suggestion <- @suggestions do %>
                <button
                  type="button"
                  phx-click="select_suggestion"
                  phx-value-suggestion={suggestion}
                  class="w-full px-4 py-2 text-left hover:bg-gray-100 first:rounded-t-lg last:rounded-b-lg"
                >
                  <%= suggestion %>
                </button>
              <% end %>
            </div>
          <% end %>
        </div>
      </div>

      <!-- Search stats -->
      <%= if @search_stats != %{} do %>
        <div class="mb-6 text-sm text-gray-600">
          Found <%= @search_stats.result_count %> results for "<%= @search_stats.query %>"
          in <%= @search_stats.search_time_ms %>ms
        </div>
      <% end %>

      <!-- Loading state -->
      <%= if @loading do %>
        <div class="flex items-center justify-center py-12">
          <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
          <span class="ml-2 text-gray-600">Searching...</span>
        </div>
      <% end %>

      <!-- Results -->
      <%= if not @loading and length(@results) > 0 do %>
        <div class="results space-y-6">
          <%= for result <- @results do %>
            <div class="result-item p-6 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
              <h3 class="text-xl font-semibold text-blue-600 mb-2">
                <%= Map.get(result, "title", "Untitled") %>
              </h3>

              <p class="text-gray-700 mb-3">
                <%= Map.get(result, "content", "") |> truncate(200) %>
              </p>

              <div class="flex items-center text-sm text-gray-500 space-x-4">
                <%= if author = Map.get(result, "author") do %>
                  <span>By <%= author %></span>
                <% end %>

                <%= if timestamp = Map.get(result, "timestamp") do %>
                  <span><%= format_timestamp(timestamp) %></span>
                <% end %>
              </div>
            </div>
          <% end %>
        </div>
      <% end %>

      <!-- No results -->
      <%= if not @loading and @query != "" and length(@results) == 0 do %>
        <div class="text-center py-12">
          <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.469.901-6.062 2.372M9.88 9.88L3.121 3.121A7.966 7.966 0 012 8.5a7.966 7.966 0 011.121 4.121"></path>
          </svg>
          <h3 class="mt-2 text-sm font-medium text-gray-900">No results found</h3>
          <p class="mt-1 text-sm text-gray-500">
            Try adjusting your search terms or browse our categories.
          </p>
        </div>
      <% end %>
    </div>
    """
  end

  # Helper functions
  defp get_search_suggestions(query) do
    case SearchService.search("#{query}*", 5) do
      {:ok, results} ->
        results
        |> Enum.map(&Map.get(&1, "title", ""))
        |> Enum.filter(&(&1 != ""))
        |> Enum.uniq()
        |> Enum.take(5)

      {:error, _} -> []
    end
  end

  defp truncate(text, length) when is_binary(text) do
    if String.length(text) <= length do
      text
    else
      String.slice(text, 0, length) <> "..."
    end
  end
  defp truncate(_, _), do: ""

  defp format_timestamp(timestamp) when is_integer(timestamp) do
    case DateTime.from_unix(timestamp) do
      {:ok, datetime} -> Calendar.strftime(datetime, "%B %d, %Y")
      _ -> "Unknown date"
    end
  end
  defp format_timestamp(_), do: "Unknown date"
end
```

## Task-based Indexing

For background indexing operations:

```elixir
defmodule MyApp.IndexingTask do
  use Task, restart: :transient
  require Logger
  alias MyApp.SearchService

  def start_link(documents) when is_list(documents) do
    Task.start_link(__MODULE__, :run, [documents])
  end

  def run(documents) do
    Logger.info("Starting bulk indexing of #{length(documents)} documents")
    start_time = System.monotonic_time(:millisecond)

    try do
      documents
      |> Enum.chunk_every(100)
      |> Enum.with_index()
      |> Enum.each(&process_batch/1)

      SearchService.commit()

      end_time = System.monotonic_time(:millisecond)
      duration = end_time - start_time

      Logger.info("Bulk indexing completed in #{duration}ms")
      :ok

    rescue
      e ->
        Logger.error("Bulk indexing failed: #{inspect(e)}")
        {:error, e}
    end
  end

  defp process_batch({batch, index}) do
    Logger.debug("Processing batch #{index + 1} with #{length(batch)} documents")

    case SearchService.add_documents(batch) do
      :ok ->
        :ok
      {:error, reason} ->
        Logger.warning("Batch #{index + 1} failed: #{inspect(reason)}")
        raise "Batch processing failed: #{reason}"
    end

    # Brief pause to prevent overwhelming the system
    if rem(index, 10) == 0 do
      Process.sleep(100)
    end
  end
end

# Usage
defmodule MyApp.DataIndexer do
  def reindex_all do
    documents = fetch_all_documents_from_database()

    # Start background indexing task
    {:ok, task} = MyApp.IndexingTask.start_link(documents)

    # Optional: Monitor the task
    ref = Process.monitor(task)

    receive do
      {:DOWN, ^ref, :process, ^task, :normal} ->
        Logger.info("Indexing completed successfully")
        :ok

      {:DOWN, ^ref, :process, ^task, reason} ->
        Logger.error("Indexing task failed: #{inspect(reason)}")
        {:error, reason}
    after
      300_000 -> # 5 minute timeout
        Process.exit(task, :timeout)
        {:error, :timeout}
    end
  end

  defp fetch_all_documents_from_database do
    # Your database query logic here
    []
  end
end
```

## Supervision Strategies

Integrate TantivyEx services into your supervision tree:

```elixir
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Database
      MyApp.Repo,

      # Search services
      MyApp.SearchService,

      # Web endpoint
      MyAppWeb.Endpoint,

      # Background tasks
      {Task.Supervisor, name: MyApp.TaskSupervisor}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
```

These integration patterns provide a solid foundation for building search functionality into your Elixir applications with proper error handling, performance considerations, and user experience optimizations.