fix: custom field substring search - pass id as parameter
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
ca5fad0dcc
commit
00fe471bc0
2 changed files with 160 additions and 3 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue