Last active
August 9, 2021 17:40
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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