def mount(_params, _session, socket) do :ok, assign(socket, search_term: "", results: [], searching: false) end
test "search finds relevant posts" do results = Blog.search_posts("elixir search") assert length(results) > 0 assert Enum.any?(results, &String.contains?(&1.title, "Elixir")) end uni ecto plugin
<%= if @searching do %> <div class="mt-4"> <h3>Found @results results</h3> <div class="space-y-2"> <%= for result <- @results do %> <div class="p-4 border rounded"> <h4 class="font-bold"><%= result.title %></h4> <p><%= result.content %></p> </div> <% end %> </div> </div> <% end %> </div> """ end end # config/config.exs config :my_app, :search, language: "english", min_word_length: 2, stop_words: ["the", "a", "an", "and", "or"], highlight: true, highlight_tag: "<mark>" 10. Testing the Search # test/my_app/search_test.exs defmodule MyApp.SearchTest do use MyApp.DataCase alias MyApp.Blog alias MyApp.Blog.Post @@ to_tsquery(
import Ecto.Query def search(queryable, search_term, fields \\ @search_fields) do search_term = format_search_term(search_term) from q in queryable, where: full_text_match(q, ^search_term, ^fields) end defp full_text_match(query, search_term, fields) do dynamic = Enum.map(fields, fn field -> dynamic([q], fragment("to_tsvector(?, ?)", unquote(@search_language), field(q, ^field))) end) combined_vector = Enum.reduce(dynamic, fn d, acc -> dynamic([q], fragment("? || ?", ^acc, ^d)) end) dynamic([q], fragment("? @@ to_tsquery(?, ?)", ^combined_vector, unquote(@search_language), ^search_term)) end defp format_search_term(term) do term |> String.trim() |> String.replace(~r/\s+/, " & ") |> sanitize_search_term() end defp sanitize_search_term(term) do term |> String.replace(~r/[^\w\s&|!()]/, " ") |> String.replace(~r/\s+/, " ") |> String.trim() end end end end # lib/my_app/blog/post.ex defmodule MyApp.Blog.Post do use Ecto.Schema use MyApp.Ecto.FullTextSearch, language: "english", fields: [:title, :content, :tags] schema "posts" do field :title, :string field :content, :string field :tags, :array, :string field :search_vector, :tsvector # Optional: precomputed vector " & ") |>
defp rank_by_relevance(query, nil), do: query defp rank_by_relevance(query, term) when term == "", do: query defp rank_by_relevance(query, term) do from q in query, select_merge: % relevance: fragment( "ts_rank(to_tsvector('english', ?), plainto_tsquery('english', ?))", fragment("coalesce(?, '') , order_by: [desc: fragment("relevance")] end end # lib/my_app/blog/blog.ex defmodule MyApp.Blog do import Ecto.Query alias MyApp.Repo alias MyApp.Blog.Post def search_posts(search_term, filters \ []) do Post |> search(search_term) # From plugin |> apply_filters(filters) |> order_by_relevance(search_term) |> Repo.all() end
from q in queryable, where: fragment( """ to_tsvector('english', ?) @@ to_tsquery('english', ?) """, fragment("concat_ws(' ', ?)", ^fields), ^search_term ) end end # lib/my_app/ecto/full_text_search.ex defmodule MyApp.Ecto.FullTextSearch do @moduledoc """ Ecto plugin for full-text search functionality """ defmacro using (opts) do quote bind_quoted: [opts: opts] do @search_language opts[:language] || "english" @search_fields opts[:fields] || []
render(conn, "index.html", query: query, results: results, total_count: length(results) ) end