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
This commit is contained in:
Moritz 2025-11-20 16:00:50 +01:00
parent 078809981d
commit 9a03485604
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 83 additions and 17 deletions

View file

@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
# Module constants
@member_search_limit 10
@default_similarity_threshold 0.2
postgres do postgres do
table "members" table "members"
repo Mv.Repo repo Mv.Repo
@ -152,9 +156,10 @@ defmodule Mv.Membership.Member do
prepare fn query, _ctx -> prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || "" q = Ash.Query.get_argument(query, :query) || ""
# 0.2 as similarity threshold (recommended) # Use default similarity threshold if not provided
# Lower value can lead to more results but also to more unspecific results # Lower value leads to more results but also more unspecific results
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 threshold =
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
if is_binary(q) and String.trim(q) != "" do if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q) q2 = String.trim(q)
@ -226,28 +231,58 @@ defmodule Mv.Membership.Member do
fragment("? % first_name", ^trimmed) or fragment("? % first_name", ^trimmed) or
fragment("? % last_name", ^trimmed) or fragment("? % last_name", ^trimmed) or
fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or
fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or fragment(
fragment("similarity(first_name, ?) > 0.2", ^trimmed) or "word_similarity(?, last_name) > ?",
fragment("similarity(last_name, ?) > 0.2", ^trimmed) or ^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) contains(email, ^trimmed)
) )
) )
|> Ash.Query.limit(10) |> Ash.Query.limit(@member_search_limit)
else else
# No search query: return all unlinked members # No search query: return all unlinked members
# Caller should use filter_by_email_match helper for email match logic # Caller should use filter_by_email_match helper for email match logic
base_query base_query
|> Ash.Query.limit(10) |> Ash.Query.limit(@member_search_limit)
end end
end end
end end
end end
# Public helper function to apply email match logic after query execution @doc """
# This should be called after using :available_for_linking with user_email argument Filters members list to return only email match if exists.
#
# If a member with matching email exists, returns only that member If a member with matching email exists in the list, returns only that member.
# Otherwise returns all members (no filtering) 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) def filter_by_email_match(members, user_email)
when is_list(members) and is_binary(user_email) do when is_list(members) and is_binary(user_email) do
# Check if any member matches the email # Check if any member matches the email
@ -262,6 +297,7 @@ defmodule Mv.Membership.Member do
end end
end end
@spec filter_by_email_match(any(), any()) :: any()
def filter_by_email_match(members, _user_email), do: members def filter_by_email_match(members, _user_email), do: members
validations do validations do
@ -436,7 +472,32 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email] identity :unique_email, [:email]
end 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 def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string() q = (opts[:query] || opts["query"] || "") |> to_string()

View file

@ -267,6 +267,7 @@ defmodule MvWeb.UserLive.Form do
|> assign_form()} |> assign_form()}
end end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show" defp return_to("show"), do: "show"
defp return_to(_), do: "index" defp return_to(_), do: "index"
@ -383,8 +384,10 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
end end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 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 defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
form = form =
if user do if user do
@ -404,10 +407,11 @@ defmodule MvWeb.UserLive.Form do
assign(socket, form: to_form(form)) assign(socket, form: to_form(form))
end end
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
defp return_path("index", _user), do: ~p"/users" defp return_path("index", _user), do: ~p"/users"
defp return_path("show", user), do: ~p"/users/#{user.id}" 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 defp load_initial_members(socket) do
user = socket.assigns.user user = socket.assigns.user
user_email = if user, do: user.email, else: nil user_email = if user, do: user.email, else: nil
@ -421,7 +425,8 @@ defmodule MvWeb.UserLive.Form do
|> assign(show_member_dropdown: false) |> assign(show_member_dropdown: false)
end 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 defp load_available_members(socket, query) do
user = socket.assigns.user user = socket.assigns.user
user_email = if user, do: user.email, else: nil user_email = if user, do: user.email, else: nil
@ -430,7 +435,7 @@ defmodule MvWeb.UserLive.Form do
assign(socket, available_members: members) assign(socket, available_members: members)
end 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 defp load_members_for_linking(user_email, search_query) do
user_email_str = if user_email, do: to_string(user_email), else: nil 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 search_query_str = if search_query && search_query != "", do: search_query, else: nil