From 9a034856043698e8ff68b4925240cffbe554b518 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 16:00:50 +0100 Subject: [PATCH] refactor: add typespecs and module constants - Add @spec for public functions in Member and UserLive.Form - Replace magic numbers with module constants: - @member_search_limit = 10 - @default_similarity_threshold = 0.2 - Add comprehensive @doc for filter_by_email_match and fuzzy_search --- lib/membership/member.ex | 89 ++++++++++++++++++++++++++----- lib/mv_web/live/user_live/form.ex | 11 ++-- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8464388..d8fb4d7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr + # Module constants + @member_search_limit 10 + @default_similarity_threshold 0.2 + postgres do table "members" repo Mv.Repo @@ -152,9 +156,10 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # 0.2 as similarity threshold (recommended) - # Lower value can lead to more results but also to more unspecific results - threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 + # Use default similarity threshold if not provided + # Lower value leads to more results but also more unspecific results + threshold = + Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) @@ -226,28 +231,58 @@ defmodule Mv.Membership.Member do fragment("? % first_name", ^trimmed) or fragment("? % last_name", ^trimmed) or fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or - fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or - fragment("similarity(first_name, ?) > 0.2", ^trimmed) or - fragment("similarity(last_name, ?) > 0.2", ^trimmed) or + fragment( + "word_similarity(?, last_name) > ?", + ^trimmed, + ^@default_similarity_threshold + ) or + fragment( + "similarity(first_name, ?) > ?", + ^trimmed, + ^@default_similarity_threshold + ) or + fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or contains(email, ^trimmed) ) ) - |> Ash.Query.limit(10) + |> Ash.Query.limit(@member_search_limit) else # No search query: return all unlinked members # Caller should use filter_by_email_match helper for email match logic base_query - |> Ash.Query.limit(10) + |> Ash.Query.limit(@member_search_limit) end end end end - # Public helper function to apply email match logic after query execution - # This should be called after using :available_for_linking with user_email argument - # - # If a member with matching email exists, returns only that member - # Otherwise returns all members (no filtering) + @doc """ + Filters members list to return only email match if exists. + + If a member with matching email exists in the list, returns only that member. + Otherwise returns all members unchanged (no filtering). + + This is typically used after calling `:available_for_linking` action with + a user_email argument to apply email-match priority logic. + + ## Parameters + - `members` - List of Member structs to filter + - `user_email` - Email string to match against member emails + + ## Returns + - List of Member structs (either single match or all members) + + ## Examples + + iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] + iex> filter_by_email_match(members, "test@example.com") + [%Member{email: "test@example.com"}] + + iex> filter_by_email_match(members, "nomatch@example.com") + [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] + + """ + @spec filter_by_email_match([t()], String.t()) :: [t()] def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do # Check if any member matches the email @@ -262,6 +297,7 @@ defmodule Mv.Membership.Member do end end + @spec filter_by_email_match(any(), any()) :: any() def filter_by_email_match(members, _user_email), do: members validations do @@ -436,7 +472,32 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - # Fuzzy Search function that can be called by live view and calls search action + @doc """ + Performs fuzzy search on members using PostgreSQL trigram similarity. + + Wraps the `:search` action with convenient opts-based argument passing. + Searches across first_name, last_name, email, and other text fields using + full-text search combined with trigram similarity. + + ## Parameters + - `query` - Ash.Query.t() to apply search to + - `opts` - Keyword list or map with search options: + - `:query` or `"query"` - Search string + - `:fields` or `"fields"` - Optional field restrictions + + ## Returns + - Modified Ash.Query.t() with search filters applied + + ## Examples + + iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!() + [%Member{first_name: "Greta", ...}] + + iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant + [%Member{first_name: "Greta", ...}] + + """ + @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t() def fuzzy_search(query, opts) do q = (opts[:query] || opts["query"] || "") |> to_string() diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 82df862..9cf3f59 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -267,6 +267,7 @@ defmodule MvWeb.UserLive.Form do |> assign_form()} end + @spec return_to(String.t() | nil) :: String.t() defp return_to("show"), do: "show" defp return_to(_), do: "index" @@ -383,8 +384,10 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end + @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = if user do @@ -404,10 +407,11 @@ defmodule MvWeb.UserLive.Form do assign(socket, form: to_form(form)) end + @spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t() defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" - # Load initial members when the form is loaded or member is unlinked + @spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp load_initial_members(socket) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -421,7 +425,8 @@ defmodule MvWeb.UserLive.Form do |> assign(show_member_dropdown: false) end - # Load members based on search query + @spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) :: + Phoenix.LiveView.Socket.t() defp load_available_members(socket, query) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -430,7 +435,7 @@ defmodule MvWeb.UserLive.Form do assign(socket, available_members: members) end - # Query available members using the Ash action + @spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] defp load_members_for_linking(user_email, search_query) do user_email_str = if user_email, do: to_string(user_email), else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil