From 142f1858e6e37dab61bc28e74b60471d5fd3f2e7 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 4 Dec 2025 15:48:40 +0100 Subject: [PATCH] feat: updates query in member ressource --- lib/membership/member.ex | 131 +++++++++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 45 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index b788dc9..78f42f7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -29,7 +29,9 @@ defmodule Mv.Membership.Member do ## Full-Text Search Members have a `search_vector` attribute (tsvector) that is automatically - updated via database trigger. Search includes name, email, notes, and contact fields. + updated via database trigger. Search includes name, email, notes, contact fields, + and all custom field values. Custom field values are automatically included in + the search vector with weight 'C' (same as phone_number, city, etc.). """ use Ash.Resource, domain: Mv.Membership, @@ -141,28 +143,16 @@ defmodule Mv.Membership.Member do q2 = String.trim(q) pat = "%" <> q2 <> "%" - # FTS as main filter and fuzzy search just for first name, last name and strees + # Build search filters grouped by search type for maintainability + # Priority: FTS > Substring > Custom Fields > Fuzzy Matching + fts_match = build_fts_filter(q2) + substring_match = build_substring_filter(q2, pat) + custom_field_match = build_custom_field_filter(pat) + fuzzy_match = build_fuzzy_filter(q2, threshold) + query |> Ash.Query.filter( - expr( - # Substring on numeric-like fields (best effort, supports middle substrings) - fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or - fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or - contains(postal_code, ^q2) or - contains(house_number, ^q2) or - contains(phone_number, ^q2) or - contains(email, ^q2) or - contains(city, ^q2) or ilike(city, ^pat) or - fragment("? % first_name", ^q2) or - fragment("? % last_name", ^q2) or - fragment("? % street", ^q2) or - fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or - fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or - fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or - fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or - fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or - fragment("similarity(street, ?) > ?", ^q2, ^threshold) - ) + expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) ) else query @@ -507,6 +497,67 @@ defmodule Mv.Membership.Member do end end + # ============================================================================ + # Search Filter Builders + # ============================================================================ + # These functions build search filters grouped by search type for maintainability. + # Priority order: FTS > Substring > Custom Fields > Fuzzy Matching + + # Builds full-text search filter using tsvector (highest priority, fastest) + # Uses GIN index on search_vector for optimal performance + defp build_fts_filter(query) do + expr( + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query) + ) + end + + # Builds substring search filter for structured fields + # Note: contains/2 uses ILIKE '%value%' which is not index-optimized + # Performance: Good for small datasets, may be slow on large tables + defp build_substring_filter(query, pattern) do + expr( + contains(postal_code, ^query) or + contains(house_number, ^query) or + contains(phone_number, ^query) or + contains(email, ^query) or + contains(city, ^query) or + ilike(city, ^pattern) + ) + end + + # Builds search filter for custom field values using LIKE on JSONB + # Note: LIKE on JSONB is not index-optimized, may be slow with many custom fields + # This is a fallback for substring matching in custom fields (e.g., phone numbers) + defp build_custom_field_filter(pattern) do + expr( + fragment( + "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND (value->>'_union_value' LIKE ? OR value->>'value' LIKE ? OR (value->'_union_value')::text LIKE ? OR (value->'value')::text LIKE ?))", + ^pattern, + ^pattern, + ^pattern, + ^pattern + ) + ) + end + + # Builds fuzzy/trigram matching filter for name and street fields + # Uses pg_trgm extension with GIN indexes for performance + # Note: Requires trigram indexes on first_name, last_name, street + defp build_fuzzy_filter(query, threshold) do + expr( + fragment("? % first_name", ^query) or + fragment("? % last_name", ^query) or + fragment("? % street", ^query) or + fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or + fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or + fragment("word_similarity(?, street) > ?", ^query, ^threshold) or + fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or + fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or + fragment("similarity(street, ?) > ?", ^query, ^threshold) + ) + 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 @@ -527,34 +578,24 @@ defmodule Mv.Membership.Member do # Search query provided: return email-match OR fuzzy-search candidates trimmed_search = String.trim(search_query) + pat = "%" <> trimmed_search <> "%" + + # Build search filters using modular functions for maintainability + fts_match = build_fts_filter(trimmed_search) + custom_field_match = build_custom_field_filter(pat) + fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold) + email_substring_match = expr(contains(email, ^trimmed_search)) + 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 exact match has highest priority (for filter_by_email_match) + # If email is "", this is always false and search filters take over 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) + ^fts_match or + ^custom_field_match or + ^fuzzy_match or + ^email_substring_match ) ) else