Skip to content

Instantly share code, notes, and snippets.

@onomated
Last active August 9, 2021 17:40
Show Gist options
  • Save onomated/36e23eb2fb80669b7e440af2a450ea7f to your computer and use it in GitHub Desktop.
Save onomated/36e23eb2fb80669b7e440af2a450ea7f to your computer and use it in GitHub Desktop.
Override/expand elixir (QueryBuilder)[https://github.com/mathieuprog/query_builder] functionality to add app specific text search functionality
defmodule MyApp.QueryBuilder do
@moduledoc """
Convenience wrapper around `QueryBuilder` (https://github.com/mathieuprog/query_builder) module
that provides a few extra utilities
"""
require Ecto.Query
import MyApp.QueryFunctions
Module.register_attribute(__MODULE__, :search_fields, accumulate: true)
@search_fields {"articles", [:title, :content]}
@search_fields {"users", [:username, :email, :first_name, :last_name]}
defmacro __using__(opts) do
quote do
require QueryBuilder
QueryBuilder.__using__(unquote(opts))
alias MyApp.QueryBuilder, as: QB
end
end
# Expose all QueryBuilder functions: QueryBuilder.__info__(:functions)
defdelegate left_join(query, assoc_fields, filters \\ [], or_filters \\ []), to: QueryBuilder
defdelegate maybe_where(query, bool, filters), to: QueryBuilder
defdelegate maybe_where(query, condition, fields, filters, or_filters \\ []), to: QueryBuilder
defdelegate new(ecto_query), to: QueryBuilder
defdelegate order_by(query, value), to: QueryBuilder
defdelegate order_by(query, assoc_fields, value), to: QueryBuilder
defdelegate preload(query, assoc_fields), to: QueryBuilder
defdelegate where(query, filters), to: QueryBuilder
defdelegate where(query, assoc_fields, filters, or_filters \\ []), to: QueryBuilder
@doc ~S"""
Allows to pass a list of operations through a keyword list.
Copied from QueryBuilder.from_list/2 but targeting this module so added functionality
can be accessed as well
Example:
```
QueryBuilder.from_list(query, [
where: [name: "John", city: "Anytown"],
preload: [articles: :comments]
])
```
"""
def from_list(query, []), do: query
def from_list(query, [{operation, arguments} | tail]) do
arguments =
cond do
is_tuple(arguments) -> Tuple.to_list(arguments)
is_list(arguments) -> [arguments]
true -> List.wrap(arguments)
end
apply(__MODULE__, operation, [query | arguments])
|> from_list(tail)
end
# Add app specific query functions
def search(query, field, search_term) do
search_fields = resolve_search_fields(field)
if !search_fields do
raise ArgumentError, """
Search not supported for specified search field: #{inspect(field)}.
Either provide the search field alias string or an explicit list of search fields
"""
end
# See: https://elixirforum.com/t/querybuilder-compose-ecto-queries-without-effort/27704/11?u=onomated
text_search_condition = fn fields, value, get_binding_fun ->
field_bindings =
fields
|> Enum.map(get_binding_fun)
do_text_search_query(field_bindings, value)
end
text_search_similarity = fn fields, get_binding_fun ->
field_bindings =
fields
|> Enum.map(get_binding_fun)
do_text_similarity_query(field_bindings, search_term)
end
query
|> where(&text_search_condition.(search_fields, search_term, &1))
|> order_by(desc: &text_search_similarity.(search_fields, &1))
end
defp do_text_search_query(field_bindings, search_term) do
search_sql = search_term_sql(search_term)
case length(field_bindings) do
1 ->
[{field1, binding1}] = field_bindings
Ecto.Query.dynamic([{^binding1, x1}], text_search(field(x1, ^field1), ^search_sql))
2 ->
[{field1, binding1}, {field2, binding2}] = field_bindings
Ecto.Query.dynamic(
[{^binding1, x1}, {^binding2, x2}],
text_search(field(x1, ^field1), field(x2, ^field2), ^search_sql)
)
n ->
raise ArgumentError, """
Unsupported number of search fields specified: #{n}
"""
end
end
defp do_text_similarity_query(field_bindings, search_term) do
case length(field_bindings) do
1 ->
[{field1, binding1}] = field_bindings
Ecto.Query.dynamic([{^binding1, x1}], similarity_concat(field(x1, ^field1), ^search_term))
2 ->
[{field1, binding1}, {field2, binding2}] = field_bindings
Ecto.Query.dynamic(
[{^binding1, x1}, {^binding2, x2}],
similarity_concat(field(x1, ^field1), field(x2, ^field2), ^search_term)
)
n ->
raise ArgumentError, """
Unsupported number of search fields specified: #{n}
"""
end
defp resolve_search_fields(term) when is_list(term), do: term
defp resolve_search_fields(term) when is_binary(term) do
search_spec =
@search_fields
|> Enum.find(&(elem(&1, 0) === term))
if is_nil(search_spec), do: nil, else: elem(search_spec, 1)
end
defp resolve_search_fields(_), do: nil
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment