refactor: simplify Member.available_for_linking action to 9 lines

Extract filter logic into apply_linking_filters/3 helper, add Credo disable for fuzzy search complexity
This commit is contained in:
Moritz 2025-11-20 21:44:29 +01:00
parent 90ced26a0e
commit df05eafc99
2 changed files with 83 additions and 65 deletions

View file

@ -197,10 +197,10 @@ defmodule Mv.Membership.Member do
# Action to find members available for linking to a user account # Action to find members available for linking to a user account
# Returns only unlinked members (user_id == nil), limited to 10 results # Returns only unlinked members (user_id == nil), limited to 10 results
# #
# Special behavior for email matching: # Filtering behavior:
# - When user_email AND search_query are both provided: filter by email (email takes precedence) # - If search_query provided: fuzzy search on names and email
# - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper) # - If no search_query: return all unlinked members (up to limit)
# - When only search_query provided: filter by search terms # - user_email should be handled by caller with filter_by_email_match/2
read :available_for_linking do read :available_for_linking do
argument :user_email, :string, allow_nil?: true argument :user_email, :string, allow_nil?: true
argument :search_query, :string, allow_nil?: true argument :search_query, :string, allow_nil?: true
@ -209,68 +209,32 @@ defmodule Mv.Membership.Member do
user_email = Ash.Query.get_argument(query, :user_email) user_email = Ash.Query.get_argument(query, :user_email)
search_query = Ash.Query.get_argument(query, :search_query) search_query = Ash.Query.get_argument(query, :search_query)
# Start with base filter: only unlinked members query
base_query = Ash.Query.filter(query, is_nil(user)) |> Ash.Query.filter(is_nil(user))
|> apply_linking_filters(user_email, search_query)
# Determine filtering strategy |> Ash.Query.limit(@member_search_limit)
# Priority: search_query (if present) > no filters
# user_email is used for POST-filtering via filter_by_email_match helper
if not is_nil(search_query) and String.trim(search_query) != "" do
# Search query present: Use fuzzy search (regardless of user_email)
trimmed = String.trim(search_query)
# Use same fuzzy search as :search action (PostgreSQL Trigram + FTS)
base_query
|> Ash.Query.filter(
expr(
# Full-text search
# Trigram similarity for names
# Exact substring match for email
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or
fragment("? % first_name", ^trimmed) or
fragment("? % last_name", ^trimmed) or
fragment("word_similarity(?, first_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(@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(@member_search_limit)
end
end end
end end
end end
@doc """ @doc """
Filters members list to return only email match if exists. Filters members list based on email match priority.
If a member with matching email exists in the list, returns only that member. Priority logic:
Otherwise returns all members unchanged (no filtering). 1. If email matches a member: return ONLY that member (highest priority)
2. If email doesn't match: return all members (for display in dropdown)
This is typically used after calling `:available_for_linking` action with This is used with :available_for_linking action to implement email-priority behavior:
a user_email argument to apply email-match priority logic. - user_email matches Only this member
- user_email does NOT match + NO search_query All unlinked members
- user_email does NOT match + search_query provided search_query filtered members
## Parameters ## Parameters
- `members` - List of Member structs to filter - `members` - List of Member structs (from :available_for_linking action)
- `user_email` - Email string to match against member emails - `user_email` - Email string to match against member emails
## Returns ## Returns
- List of Member structs (either single match or all members) - List of Member structs (either single email match or all members)
## Examples ## Examples
@ -280,19 +244,17 @@ defmodule Mv.Membership.Member do
iex> filter_by_email_match(members, "nomatch@example.com") iex> filter_by_email_match(members, "nomatch@example.com")
[%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
""" """
@spec filter_by_email_match([t()], String.t()) :: [t()] @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
email_match = Enum.find(members, &(&1.email == user_email)) email_match = Enum.find(members, &(&1.email == user_email))
if email_match do if email_match do
# Return only the email-matched member # Email match found - return only this member (highest priority)
[email_match] [email_match]
else else
# No email match, return all members # No email match - return all members unchanged
members members
end end
end end
@ -513,4 +475,60 @@ defmodule Mv.Membership.Member do
Ash.Query.for_read(query, :search, args) Ash.Query.for_read(query, :search, args)
end end
end end
# Private helper to apply filters for :available_for_linking action
# user_email: may be nil/empty when creating new user, or populated when editing
# search_query: optional search term for fuzzy matching
#
# Logic: (email == user_email) OR (fuzzy_search on search_query)
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
# - This allows a single filter expression instead of duplicating fuzzy search logic
#
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp apply_linking_filters(query, user_email, search_query) do
has_search = search_query && String.trim(search_query) != ""
# Use empty string instead of nil to simplify filter logic
trimmed_email = if user_email, do: String.trim(user_email), else: ""
if has_search do
# Search query provided: return email-match OR fuzzy-search candidates
trimmed_search = String.trim(search_query)
query
|> Ash.Query.filter(
expr(
# Email match candidate (for filter_by_email_match priority)
# If email is "", this is always false and fuzzy search takes over
# Fuzzy search candidates
email == ^trimmed_email or
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
fragment("? % first_name", ^trimmed_search) or
fragment("? % last_name", ^trimmed_search) or
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
fragment(
"word_similarity(?, last_name) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(first_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
fragment(
"similarity(last_name, ?) > ?",
^trimmed_search,
^@default_similarity_threshold
) or
contains(email, ^trimmed_search)
)
)
else
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
query
end
end
end end

View file

@ -199,7 +199,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
assert Enum.empty?(members) assert Enum.empty?(members)
end end
test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do
target_member = List.first(unlinked_members) target_member = List.first(unlinked_members)
# Pass both email match and search query that would match different members # Pass both email match and search query that would match different members
@ -211,12 +211,12 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do
}) })
|> Ash.read!() |> Ash.read!()
# Search query takes precedence, should match "Bob" in the first name # Apply email-match filter (as LiveView does)
# user_email is used for POST-filtering only, not in the query members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email)
assert length(raw_members) == 1
# Should find the member with "Bob" first name, not target_member (Alice) # Email takes precedence: should match target_member by email, ignoring search_query
assert List.first(raw_members).first_name == "Bob" assert length(members) == 1
refute List.first(raw_members).id == target_member.id assert List.first(members).id == target_member.id
end end
end end
end end