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:
parent
a67a2780b7
commit
814b84c120
2 changed files with 83 additions and 17 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue