Implement full-text search for members closes #11 #163

Merged
carla merged 6 commits from feature/11-fulltext-search into main 2025-09-29 14:26:38 +02:00
8 changed files with 406 additions and 0 deletions

View file

@ -166,6 +166,11 @@ defmodule Mv.Membership.Member do
attribute :postal_code, :string do attribute :postal_code, :string do
allow_nil? true allow_nil? true
end end
attribute :search_vector, AshPostgres.Tsvector,
writable?: false,
public?: false,
select_by_default?: false
end end
relationships do relationships do

View file

@ -0,0 +1,61 @@
defmodule MvWeb.Components.SearchBarComponent do
@moduledoc """
Provides the SearchBar Live-Component.
- uses the DaisyUI search input field
- sends search_changed event to parent live view with a query
"""
use MvWeb, :live_component
@impl true
def update(_assigns, socket) do
socket =
socket
|> assign_new(:query, fn -> "" end)
|> assign_new(:placeholder, fn -> gettext("Search...") end)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
placeholder={@placeholder}
value={@query}
name="query"
phx-debounce="300"
/>
</label>
</form>
"""
end
@impl true
# Function to handle the search
def handle_event("search", %{"query" => q}, socket) do
# Forward a high level message to the parent
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
end
end

View file

@ -1,5 +1,7 @@
defmodule MvWeb.MemberLive.Index do defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Expr
import Ash.Query
import MvWeb.TableComponents import MvWeb.TableComponents
@impl true @impl true
@ -10,12 +12,39 @@ defmodule MvWeb.MemberLive.Index do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Members")) |> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign(:sort_field, :first_name) |> assign(:sort_field, :first_name)
|> assign(:sort_order, :asc) |> assign(:sort_order, :asc)
|> assign(:members, sorted) |> assign(:members, sorted)
|> assign(:selected_members, [])} |> assign(:selected_members, [])}
end end
# -----------------------------------------------------------------
# Receive messages from any toolbar component
# -----------------------------------------------------------------
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
members =
if String.trim(q) == "" do
Ash.read!(Mv.Membership.Member)
else
Mv.Membership.Member
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q)))
|> Ash.read!()
end
{:noreply,
socket
|> assign(:query, q)
|> assign(:members, members)}
end
# -----------------------------------------------------------------
# Handle Events
# -----------------------------------------------------------------
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id) member = Ash.get!(Mv.Membership.Member, id)

View file

@ -8,6 +8,13 @@
</:actions> </:actions>
</.header> </.header>
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.table <.table
id="members" id="members"
rows={@members} rows={@members}

View file

@ -0,0 +1,60 @@
defmodule Mv.Repo.Migrations.AddSearchVectorToMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
add :search_vector, :tsvector
end
execute("""
CREATE INDEX members_search_vector_idx
ON members
USING GIN (search_vector)
""")
# Eigene Trigger-Funktion mit Gewichtung
execute("""
CREATE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
execute("""
CREATE TRIGGER update_search_vector
BEFORE INSERT OR UPDATE ON members
FOR EACH ROW
EXECUTE FUNCTION members_search_vector_trigger()
carla marked this conversation as resolved

This constructs the search vector for all new future members, but can we run it in this migration for all existing members as well?

This constructs the search vector for all new future members, but can we run it in this migration for all existing members as well?

But is this a use case?
I would rather put that in an own low priority issue due to priorisation if that's fine?

But is this a use case? I would rather put that in an own low priority issue due to priorisation if that's fine?

Yeah, I think if we all just reset our database we should be fine as well. No need to create an issue. I was just confused that I could not find all members in my database :D

Yeah, I think if we all just reset our database we should be fine as well. No need to create an issue. I was just confused that I could not find all members in my database :D
""")
end
def down do
execute("DROP TRIGGER IF EXISTS update_search_vector ON members")
execute("DROP FUNCTION IF EXISTS members_search_vector_trigger()")
execute("DROP INDEX IF EXISTS members_search_vector_idx")
alter table(:members) do
remove :search_vector
end
end
end

View file

@ -0,0 +1,199 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "birth_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "paid",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vectors",
"type": "tsvector"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "3B162FD69B92BF8258DB56BA0CBB6108FBE996B1F7231C5F2D9EC53D956EFC75",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,33 @@
defmodule MvWeb.Components.SearchBarComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "SearchBarComponent" do
test "renders with placeholder", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "input[placeholder='Search...']")
end
test "updates query when user types", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# simulate search input and check that other members are not listed
html =
view
|> element("form[role=search]")
|> render_change(%{"query" => "Friedrich"})
refute html =~ "Greta"
html =
view
|> element("form[role=search]")
|> render_change(%{"query" => "Greta"})
refute html =~ "Friedrich"
end
end
end

View file

@ -73,4 +73,16 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(index_view, "#flash-group", "Member create successfully") assert has_element?(index_view, "#flash-group", "Member create successfully")
end end
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
send(view.pid, {:search_changed, "Friedrich"})
state = :sys.get_state(view.pid)
carla marked this conversation as resolved

a bit picky, but this comment should be in english :)

a bit picky, but this comment should be in english :)
assert state.socket.assigns.query == "Friedrich"
assert is_list(state.socket.assigns.members)
end
end end