feat: add trigram search

This commit is contained in:
Zach Daniel 2020-05-31 01:51:41 -04:00
parent 9765d58277
commit e4d6381428
No known key found for this signature in database
GPG key ID: C377365383138D4B
4 changed files with 231 additions and 1 deletions

View file

@ -1 +1,9 @@
# AshPostgres
# TODO
## Configuration
- Need to figure out how to only fetch config one time in the configuration of the repo.
Right now, we are calling the `installed_extensions()` function in both `supervisor` and
`runtime` but that could mean checking the system environment variables every time (is that bad?)

View file

@ -17,6 +17,7 @@ defmodule AshPostgres do
)
alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or}
alias AshPostgres.Predicates.Trigram
@moduledoc """
A postgres data layer that levereges Ecto's postgres tools.
@ -67,6 +68,21 @@ defmodule AshPostgres do
resource.repo()
end
@impl true
def custom_filters(resource) do
config = repo(resource).config()
add_pg_trgm_search(%{}, config)
end
defp add_pg_trgm_search(filters, config) do
if "pg_trgm" in config[:installed_extensions] do
Map.update(filters, :string, [{:trigram, AshPostgres.Predicates.Trigram}], fn filters ->
[{:trigram, AshPostgres.Predicates.Trigram} | filters]
end)
end
end
import Ecto.Query, only: [from: 2]
@impl true
@ -82,6 +98,7 @@ defmodule AshPostgres do
def can?({:filter, :and}), do: true
def can?({:filter, :or}), do: true
def can?({:filter, :not}), do: true
def can?({:filter, :trigram}), do: true
def can?({:filter_related, _}), do: true
def can?(_), do: false
@ -443,7 +460,7 @@ defmodule AshPostgres do
not_filter ->
{params, new_expr} = filter_to_expr(not_filter, bindings, params, current_binding, path)
{params, join_exprs(expr, {:not, new_expr}, :and)}
{params, join_exprs(expr, {:not, [], [new_expr]}, :and)}
end
{params, expr} =
@ -505,6 +522,72 @@ defmodule AshPostgres do
]}}}
end
defp filter_value_to_expr(
attribute,
%Trigram{} = trigram,
current_binding,
params
) do
param_count = Enum.count(params)
case trigram do
%{equals: equals, greater_than: nil, less_than: nil, text: text} ->
{params ++ [{text, {current_binding, attribute}}, {equals, :float}],
{:fragment, [],
[
raw: "similarity(",
expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []},
raw: ", ",
expr: {:^, [], [param_count]},
raw: ") = ",
expr: {:^, [], [param_count + 1]},
raw: ""
]}}
%{equals: nil, greater_than: greater_than, less_than: nil, text: text} ->
{params ++ [{text, {current_binding, attribute}}, {greater_than, :float}],
{:fragment, [],
[
raw: "similarity(",
expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []},
raw: ", ",
expr: {:^, [], [param_count]},
raw: ") > ",
expr: {:^, [], [param_count + 1]},
raw: ""
]}}
%{equals: nil, greater_than: nil, less_than: less_than, text: text} ->
{params ++ [{text, {current_binding, attribute}}, {less_than, :float}],
{:fragment, [],
[
raw: "similarity(",
expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []},
raw: ", ",
expr: {:^, [], [param_count]},
raw: ") < ",
expr: {:^, [], [param_count + 1]},
raw: ""
]}}
%{equals: nil, greater_than: greater_than, less_than: less_than, text: text} ->
{params ++
[{text, {current_binding, attribute}}, {less_than, :float}, {greater_than, :float}],
{:fragment, [],
[
raw: "similarity(",
expr: {{:., [], [{:&, [], [current_binding]}, attribute]}, [], []},
raw: ", ",
expr: {:^, [], [param_count]},
raw: ") BETWEEN ",
expr: {:^, [], [param_count + 1]},
raw: " AND ",
expr: {:^, [], [param_count + 2]},
raw: ""
]}}
end
end
defp filter_value_to_expr(
attribute,
%And{left: left, right: right},

110
lib/predicates/trigram.ex Normal file
View file

@ -0,0 +1,110 @@
defmodule AshPostgres.Predicates.Trigram do
defstruct [:text, :greater_than, :less_than, :equals]
def new(_resource, attr_name, attr_type, opts) do
with :ok <- required_options_provided(opts),
{:ok, value} <- Ash.Type.cast_input(attr_type, opts[:text]),
{:ok, less_than} <- validate_similarity(opts[:less_than]),
{:ok, greater_than} <- validate_similarity(opts[:greater_than]),
{:ok, equals} <- validate_similarity(opts[:equals]) do
{:ok,
%__MODULE__{text: value, greater_than: greater_than, less_than: less_than, equals: equals}}
else
_ ->
{:error,
Ash.Error.Filter.InvalidFilterValue.exception(
filter: %__MODULE__{
text: opts[:text],
less_than: opts[:less_than],
greater_than: opts[:greater_than],
equals: opts[:equals]
},
value: opts,
field: attr_name
)}
end
end
defp validate_similarity(nil), do: {:ok, nil}
defp validate_similarity(1), do: {:ok, 1}
defp validate_similarity(0), do: {:ok, 0}
defp validate_similarity(similarity)
when is_float(similarity) and similarity <= 1.0 and similarity >= 0.0 do
{:ok, similarity}
end
defp validate_similarity(similarity) when is_binary(similarity) do
sanitized =
case similarity do
"." <> decimal_part -> "0." <> decimal_part
other -> other
end
case Float.parse(sanitized) do
{float, ""} -> {:ok, float}
_ -> :error
end
end
defp required_options_provided(opts) do
if Keyword.has_key?(opts, :text) do
case {opts[:greater_than], opts[:less_than], opts[:equals]} do
{nil, nil, nil} -> :error
{nil, nil, _equals} -> :ok
{_greater_than, nil, nil} -> :ok
{nil, _less_than, nil} -> :ok
{_greater_than, _less_than, nil} -> :ok
end
else
:error
end
end
end
defimpl Inspect, for: AshPostgres.Predicates.Trigram do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{text: text, equals: nil, less_than: nil, greater_than: greater_than}, opts) do
concat([
attr(opts),
" trigram similarity to ",
to_doc(text, opts),
" is greater than ",
to_doc(greater_than, opts)
])
end
def inspect(%{text: text, equals: nil, less_than: less_than, greater_than: nil}, opts) do
concat([
attr(opts),
" trigram similarity to ",
to_doc(text, opts),
" is less than ",
to_doc(less_than, opts)
])
end
def inspect(%{text: text, equals: nil, less_than: less_than, greater_than: greater_than}, opts) do
concat([
attr(opts),
" trigram similarity to ",
to_doc(text, opts),
" is between ",
to_doc(less_than, opts),
" and ",
to_doc(greater_than, opts)
])
end
def inspect(%{text: text, equals: equals, less_than: nil, greater_than: nil}, opts) do
concat([
attr(opts),
" trigram similarity to ",
to_doc(text, opts),
" == ",
to_doc(equals, opts)
])
end
end

29
lib/repo.ex Normal file
View file

@ -0,0 +1,29 @@
defmodule AshPostgres.Repo do
@callback installed_extensions() :: [String.t()]
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
otp_app = opts[:otp_app] || raise("Must configure OTP app")
use Ecto.Repo,
adapter: Ecto.Adapters.Postgres,
otp_app: otp_app
def installed_extensions() do
[]
end
def init(:supervisor, config) do
new_config = Keyword.put(config, :installed_extensions, installed_extensions())
{:ok, new_config}
end
def init(:runtime, config) do
init(:supervisor, config)
end
defoverridable installed_extensions: 0
end
end
end