feat: updates query in member ressource

This commit is contained in:
carla 2025-12-04 15:48:40 +01:00
parent b025be5932
commit 142f1858e6

View file

@ -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