berrypod/lib/simpleshop_theme/search.ex
jamey 57c3ba0e28 wire shop LiveViews to DB queries and improve search UX
Replace PreviewData indirection in all shop LiveViews with direct
Products context queries. Home, collection, product detail and error
pages now query the database. Categories loaded once in ThemeHook.
Cart hydration no longer falls back to mock data. PreviewData kept
only for the theme editor.

Search modal gains keyboard navigation (arrow keys, Enter, Escape),
Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate
links, and 150ms debounce. SearchModal JS hook manages selection
state and highlight. search.ex gets transaction safety on reindex
and a public remove_product/1. 10 new integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:27:26 +00:00

195 lines
5.0 KiB
Elixir

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)
search_fts(fts_query)
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
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("&amp;", "&")
|> String.replace("&lt;", "<")
|> String.replace("&gt;", ">")
|> String.replace("&quot;", "\"")
|> String.replace("&#39;", "'")
|> String.replace("&nbsp;", " ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
end