defmodule Predicated do
require Logger
alias Predicated.Query
alias Predicated.Predicate
def test(predicates, subject, acc \\ [])
def test(query, subject, acc) when is_binary(query) do
case Query.new(query) do
{:ok, predicates} ->
test(predicates, subject, acc)
error ->
IO.inspect(error: error)
Logger.error("Could not parse the query")
end
end
@doc """
Tests a predicate
"""
# This handles the case when there are group predicates.
# ex. (b == 1 || b == 2) && c == 3
def test([%{condition: nil, predicates: predicates} = predicate | rest], subject, acc)
when length(predicates) > 0 do
result = {predicate.logical_operator, test(predicates, subject, []), nil}
test(rest, subject, [result | acc])
end
# This handles the case when there are non-grouped predicates.
# ex. b == 1 || b == 2 && c == 3
def test(
[%{condition: condition, predicates: predicates} = predicate | rest],
subject,
acc
)
when not is_nil(condition) and length(predicates) == 0 do
result = {predicate.logical_operator, eval(predicate.condition, subject), nil}
test(rest, subject, [result | acc])
end
# This handles the case when we have tested all predicates and we need to compile the final result
def test([], _subject, acc) do
compile(Enum.reverse(acc), nil, true)
end
@doc """
Compiles the final result of testing all the predicates
"""
# first result to be compiled
def compile([{_operator, bool, _} = result | rest], nil, acc) do
compile(rest, result, acc && bool)
end
# if the previous result was an and
def compile([{_operator, bool, _} = result | rest], {:and, _result, _}, acc) do
compile(rest, result, acc && bool)
end
# if the previous result was an or
def compile([{_operator, bool, _} = result | rest], {:or, _result, _}, acc) do
compile(rest, result, acc || bool)
end
# no more to results to compile
def compile([], _previous, acc) do
acc
end
@doc """
Evaluates a predicate's condition
"""
def eval(%{identifier: identifier, comparison_operator: "==", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) == :eq
datetime?(expression) ->
DateTime.compare(subject_value, expression) == :eq
true ->
subject_value == expression
end
end
def eval(%{identifier: identifier, comparison_operator: "!=", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) != :eq
datetime?(expression) ->
DateTime.compare(subject_value, expression) != :eq
true ->
subject_value != expression
end
end
def eval(%{identifier: identifier, comparison_operator: ">", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) == :gt
datetime?(expression) ->
DateTime.compare(subject_value, expression) == :gt
true ->
subject_value > expression
end
end
def eval(%{identifier: identifier, comparison_operator: ">=", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) in [:eq, :gt]
datetime?(expression) ->
DateTime.compare(subject_value, expression) in [:eq, :gt]
true ->
subject_value >= expression
end
end
def eval(%{identifier: identifier, comparison_operator: "<", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) == :lt
datetime?(expression) ->
DateTime.compare(subject_value, expression) == :lt
true ->
subject_value < expression
end
end
def eval(%{identifier: identifier, comparison_operator: "<=", expression: expression}, subject) do
{_path, subject_value} = path_and_value(identifier, subject)
cond do
date?(expression) ->
Date.compare(subject_value, expression) in [:eq, :lt]
datetime?(expression) ->
DateTime.compare(subject_value, expression) in [:eq, :lt]
true ->
subject_value <= expression
end
end
def eval(
%{identifier: identifier, comparison_operator: "contains", expression: expression},
subject
) do
{_path, subject_value} = path_and_value(identifier, subject)
expression in subject_value
end
def eval(
%{identifier: identifier, comparison_operator: "in", expression: expression},
subject
) do
{_path, subject_value} = path_and_value(identifier, subject)
subject_value in expression
end
# TODO add other operators like like, contains, in, starts_with etc
# fallback case if no other conditions match
def eval(_, _) do
nil
end
@doc """
Constructs the path used to query into the subject and extracts the expression from the subject based on that path.
iex> Predicated.path_and_value("person.first_name", %{person: %{first_name: "Bob"}})
{[:person, :first_name], "Bob"}
iex> Predicated.path_and_value("first_name", %{first_name: "Bob"})
{[:first_name], "Bob"}
"""
def path_and_value(identifier, subject) do
path = String.split(identifier, ".") |> Enum.map(&String.to_atom/1)
{path, get_in(subject, path)}
end
def date?(%Date{} = _date), do: true
def date?(_), do: false
def datetime?(%DateTime{} = _datetime), do: true
def datetime?(%NaiveDateTime{} = _datetime), do: true
def datetime?(_), do: false
def to_query(predicates) do
Enum.reduce(predicates, [], fn predicate, acc ->
[Predicate.to_query(predicate), acc]
end)
|> Enum.reverse()
|> Enum.join(" ")
end
end