Implement fuzzy search #187

Merged
carla merged 10 commits from feature/162_fuzzy_search into main 2025-11-12 13:10:32 +01:00
Showing only changes of commit f6bfeadb7b - Show all commits

View file

@ -3,6 +3,11 @@ defmodule Mv.Membership.Member do
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer
require Ash.Query
import Ash.Expr
@default_fields [:first_name, :last_name, :email, :phone_number, :city, :street, :house_number, :postal_code]
postgres do
table "members"
repo Mv.Repo
@ -108,6 +113,47 @@ defmodule Mv.Membership.Member do
where [changing(:user)]
end
end
read :search do
argument :query, :string, allow_nil?: true
argument :fields, {:array, :atom}, allow_nil?: true
argument :similarity_threshold, :float, allow_nil?: true
prepare fn query, _ctx ->
q = Ash.Query.get_argument(query, :query) || ""
fields = Ash.Query.get_argument(query, :fields) || @default_fields
threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2
carla marked this conversation as resolved

some moduledocs would be nice for the public search function.

some moduledocs would be nice for the public `search` function.
if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q)
pat = "%" <> q2 <> "%"
# FTS as main filter and fuzzy search just fo first name, last name and strees
query
|> Ash.Query.filter(
carla marked this conversation as resolved

the fields argument is never used in the search function.

the `fields` argument is never used in the `search` function.
expr(
carla marked this conversation as resolved Outdated

Maybe some documentation, what this 0.2 threshold means or where does it came from?

Maybe some documentation, what this `0.2` threshold means or where does it came from?
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
# Substring on numeric-like fields (best effort, supports middle substrings)
contains(postal_code, ^q2) or
contains(house_number, ^q2) or
contains(phone_number, ^q2) or
carla marked this conversation as resolved Outdated

little typo: fo

little typo: `fo`
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)
)
)
else
query
end
carla marked this conversation as resolved

maybe the duplicate code for first name last name and street could be generated dynamically by the fields argument?

maybe the duplicate code for `first name` `last name` and `street` could be generated dynamically by the fields argument?

I decided to omit fields now...but maybe during refactoring we could think about a dynamic way

I decided to omit fields now...but maybe during refactoring we could think about a dynamic way
end
end
carla marked this conversation as resolved

filter for email is missing
adding this worked for me:

                fragment("? % email", ^q2) or
                fragment("word_similarity(?, email) > ?", ^q2, ^threshold) or
                fragment("similarity(email, ?) > ?", ^q2, ^threshold) or
filter for email is missing adding this worked for me: ``` fragment("? % email", ^q2) or fragment("word_similarity(?, email) > ?", ^q2, ^threshold) or fragment("similarity(email, ?) > ?", ^q2, ^threshold) or ```

You're totally right, i forgot the email field. But I would favor for a simple contains ilike on the email field, because I think fuzzy search on the name already is enough? or what do you think?

You're totally right, i forgot the email field. But I would favor for a simple contains ilike on the email field, because I think fuzzy search on the name already is enough? or what do you think?

I added it as ilike and not as fuzzy search. But we can create another issue if we see we need it :)

I added it as ilike and not as fuzzy search. But we can create another issue if we see we need it :)
end
validations do
@ -281,4 +327,21 @@ defmodule Mv.Membership.Member do
identities do
identity :unique_email, [:email]
end
# Fuzzy Search function that can be called by live view and calls search action
def fuzzy_search(query, opts) do
q = (opts[:query] || opts["query"] || "") |> to_string()
if String.trim(q) == "" do
query
else
args =
case (opts[:fields] || opts["fields"]) do
nil -> %{query: q}
fields -> %{query: q, fields: fields}
end
Ash.Query.for_read(query, :search, args)
end
end
end