fix: custom field substring search - pass id as parameter
All checks were successful
continuous-integration/drone/push Build is passing

Fragment 'member_id = id' did not resolve correctly. Now passes id as
Ash expression. Also changed LIKE to ILIKE for case-insensitive search.
This commit is contained in:
Moritz 2025-12-11 14:04:13 +01:00
parent ca5fad0dcc
commit 00fe471bc0
2 changed files with 160 additions and 3 deletions

View file

@ -561,14 +561,16 @@ defmodule Mv.Membership.Member do
) )
end end
# Builds search filter for custom field values using LIKE on JSONB # Builds search filter for custom field values using ILIKE on JSONB
# Note: LIKE on JSONB is not index-optimized, may be slow with many custom fields # 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) # 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) # 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 defp build_custom_field_filter(pattern) do
expr( expr(
fragment( 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,
^pattern ^pattern
) )

View file

@ -544,4 +544,159 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
# Substring matching for custom fields may need to be implemented separately # Substring matching for custom fields may need to be implemented separately
end end
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 end