diff --git a/lib/membership/member.ex b/lib/membership/member.ex index ee0622d..d29a759 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -561,14 +561,16 @@ defmodule Mv.Membership.Member do ) 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 + # Builds search filter for custom field values using ILIKE on JSONB + # Note: ILIKE 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) # Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback) + # Important: `id` must be passed as parameter to correctly reference the outer members table 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 ?))", + "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))", + id, ^pattern, ^pattern ) diff --git a/test/membership/member_search_with_custom_fields_test.exs b/test/membership/member_search_with_custom_fields_test.exs index 3b1b3b9..6711df8 100644 --- a/test/membership/member_search_with_custom_fields_test.exs +++ b/test/membership/member_search_with_custom_fields_test.exs @@ -544,4 +544,159 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do # Substring matching for custom fields may need to be implemented separately end end + + describe "custom field substring search (ILIKE)" do + test "finds member by prefix of custom field value", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value with a distinct word + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "Premium"} + }) + |> Ash.create() + + # Test prefix searches - should all find the member + for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do + results = + Member + |> Member.fuzzy_search(%{query: prefix}) + |> Ash.read!() + + assert Enum.any?(results, fn m -> m.id == member1.id end), + "Prefix '#{prefix}' should find member with custom field 'Premium'" + end + end + + test "custom field search is case-insensitive", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "GoldMember"} + }) + |> Ash.create() + + # Test case variations - should all find the member + for variant <- [ + "GoldMember", + "goldmember", + "GOLDMEMBER", + "GoLdMeMbEr", + "gold", + "GOLD", + "Gold" + ] do + results = + Member + |> Member.fuzzy_search(%{query: variant}) + |> Ash.read!() + + assert Enum.any?(results, fn m -> m.id == member1.id end), + "Case variant '#{variant}' should find member with custom field 'GoldMember'" + end + end + + test "finds member by suffix/middle of custom field value", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "ActiveMember"} + }) + |> Ash.create() + + # Test suffix and middle substring searches + for substring <- ["Member", "ember", "tiveMem", "ctive"] do + results = + Member + |> Member.fuzzy_search(%{query: substring}) + |> Ash.read!() + + assert Enum.any?(results, fn m -> m.id == member1.id end), + "Substring '#{substring}' should find member with custom field 'ActiveMember'" + end + end + + test "finds correct member among multiple with different custom field values", %{ + member1: member1, + member2: member2, + member3: member3, + string_field: string_field + } do + # Create different custom field values for each member + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "Beginner"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "Advanced"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "Expert"} + }) + |> Ash.create() + + # Search for "Begin" - should only find member1 + results_begin = + Member + |> Member.fuzzy_search(%{query: "Begin"}) + |> Ash.read!() + + assert length(results_begin) == 1 + assert List.first(results_begin).id == member1.id + + # Search for "Advan" - should only find member2 + results_advan = + Member + |> Member.fuzzy_search(%{query: "Advan"}) + |> Ash.read!() + + assert length(results_advan) == 1 + assert List.first(results_advan).id == member2.id + + # Search for "Exper" - should only find member3 + results_exper = + Member + |> Member.fuzzy_search(%{query: "Exper"}) + |> Ash.read!() + + assert length(results_exper) == 1 + assert List.first(results_exper).id == member3.id + end + + # Note: Legacy format (type/value) is supported via the SQL ILIKE query on value->>'value' + # This is tested implicitly by the migration trigger which handles both formats. + # The Ash union type only accepts the new format (_union_type/_union_value) for creation, + # but the search works on existing legacy data. + end end