defmodule SimpleshopTheme.Search do @moduledoc """ Full-text product search backed by SQLite FTS5. Uses a contentless FTS5 index with BM25 ranking. The index is rebuilt from the products table after each provider sync. """ import Ecto.Query alias SimpleshopTheme.Products.Product alias SimpleshopTheme.Repo @listing_preloads [images: :image] # BM25 column weights: title(10), category(5), variant_info(3), description(1) @bm25_weights "10.0, 5.0, 3.0, 1.0" @doc """ Searches products by query string. Returns ranked list of Product structs with listing preloads, or empty list for blank/short queries. """ def search(query) when is_binary(query) do query = String.trim(query) if String.length(query) < 2 do [] else fts_query = build_fts_query(query) case search_fts(fts_query) do [] -> search_like(query) results -> results end end end def search(_), do: [] @doc """ Rebuilds the entire FTS5 index from visible, active products. """ def rebuild_index do Repo.transaction(fn -> # Clear existing index data Repo.query!("DELETE FROM products_search_map") Repo.query!("DELETE FROM products_search") # Load all visible products with variants for indexing products = Product |> where([p], p.visible == true and p.status == "active") |> preload([:variants]) |> Repo.all() Enum.each(products, &insert_into_index/1) end) end @doc """ Indexes or reindexes a single product. Removes existing entry first if present. """ def index_product(%Product{} = product) do product = Repo.preload(product, [:variants], force: true) Repo.transaction(fn -> remove_from_index(product.id) insert_into_index(product) end) end @doc """ Removes a product from the search index. """ def remove_product(product_id) do remove_from_index(product_id) end # Build an FTS5 MATCH query from user input. # Strips special chars, splits into tokens, adds * prefix match to last token. defp build_fts_query(input) do tokens = input |> String.replace(~r/[^\w\s]/, "") |> String.split(~r/\s+/, trim: true) case tokens do [] -> nil tokens -> {complete, [last]} = Enum.split(tokens, -1) parts = Enum.map(complete, &~s("#{&1}")) ++ [~s("#{last}" *)] Enum.join(parts, " ") end end defp search_fts(nil), do: [] defp search_fts(fts_query) do result = Repo.query!( """ SELECT m.product_id, bm25(products_search, #{@bm25_weights}) AS rank FROM products_search JOIN products_search_map m ON m.rowid = products_search.rowid WHERE products_search MATCH ?1 ORDER BY rank LIMIT 20 """, [fts_query] ) product_ids = Enum.map(result.rows, fn [id, _rank] -> id end) if product_ids == [] do [] else # Fetch full structs preserving rank order products_by_id = Product |> where([p], p.id in ^product_ids) |> where([p], p.visible == true and p.status == "active") |> preload(^@listing_preloads) |> Repo.all() |> Map.new(&{&1.id, &1}) Enum.flat_map(product_ids, fn id -> case Map.get(products_by_id, id) do nil -> [] product -> [product] end end) end end # Substring fallback when FTS5 prefix matching returns nothing defp search_like(query) do pattern = "%#{sanitize_like(query)}%" Product |> where([p], p.visible == true and p.status == "active") |> where([p], like(p.title, ^pattern) or like(p.category, ^pattern)) |> order_by([p], p.title) |> limit(20) |> preload(^@listing_preloads) |> Repo.all() end defp sanitize_like(input) do input |> String.replace("\\", "\\\\") |> String.replace("%", "\\%") |> String.replace("_", "\\_") end defp insert_into_index(%Product{} = product) do Repo.query!( "INSERT INTO products_search_map (product_id) VALUES (?1)", [product.id] ) %{rows: [[rowid]]} = Repo.query!("SELECT last_insert_rowid()") variant_info = build_variant_info(product.variants || []) description = strip_html(product.description || "") Repo.query!( """ INSERT INTO products_search (rowid, title, category, variant_info, description) VALUES (?1, ?2, ?3, ?4, ?5) """, [rowid, product.title || "", product.category || "", variant_info, description] ) end defp remove_from_index(product_id) do case Repo.query!( "SELECT rowid FROM products_search_map WHERE product_id = ?1", [product_id] ) do %{rows: [[rowid]]} -> Repo.query!("DELETE FROM products_search WHERE rowid = ?1", [rowid]) Repo.query!("DELETE FROM products_search_map WHERE rowid = ?1", [rowid]) _ -> :ok end end # Build searchable variant text from enabled variants defp build_variant_info(variants) do variants |> Enum.filter(& &1.is_enabled) |> Enum.flat_map(fn v -> [v.title | Map.values(v.options || %{})] end) |> Enum.uniq() |> Enum.join(" ") end # Strip HTML tags and decode common entities defp strip_html(html) do html |> String.replace(~r/<[^>]+>/, " ") |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace(""", "\"") |> String.replace("'", "'") |> String.replace(" ", " ") |> String.replace(~r/\s+/, " ") |> String.trim() end end