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:
parent
90ced26a0e
commit
df05eafc99
2 changed files with 83 additions and 65 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue