Skip to content

Commit

Permalink
adding support for nesting and grouping (#2)
Browse files Browse the repository at this point in the history
* adding support for nesting and grouping

* cleaning up
  • Loading branch information
themusicman committed Dec 4, 2023
1 parent a7d9bb5 commit cca03c4
Show file tree
Hide file tree
Showing 4 changed files with 301 additions and 35 deletions.
2 changes: 1 addition & 1 deletion lib/predicated/predicate.ex
@@ -1,4 +1,4 @@
defmodule Predicated.Predicate do
# patient_id == 1 AND (provider_id == 2 OR provider_id == 3)
defstruct condition: nil, logical_operator: :and, predicates: []
defstruct condition: nil, logical_operator: nil, predicates: []
end
90 changes: 68 additions & 22 deletions lib/predicated/query.ex
Expand Up @@ -8,22 +8,8 @@ defmodule Predicated.Query do
{:ok, results, "", _, _, _} ->
results =
results
|> Enum.chunk_every(4)
|> Enum.reduce([], fn result, acc ->
# IO.inspect(result: result)

[
%Predicate{
condition: %Condition{
identifier: Keyword.get(result, :identifier),
comparison_operator: Keyword.get(result, :comparison_operator),
expression: Keyword.get(result, :expression)
},
logical_operator: get_logical_operator(result)
}
| acc
]
end)
|> chunk_results()
|> compile_results([])
|> Enum.reverse()

{:ok, results}
Expand All @@ -33,13 +19,73 @@ defmodule Predicated.Query do
end
end

def get_logical_operator(result) do
logical_operator = Keyword.get(result, :logical_operator, "")
def chunk_results(results) do
chunk_fun = fn
element, acc when is_binary(element) ->
{:cont, Enum.reverse([element | acc]), []}

element, acc ->
{:cont, [element | acc]}
end

if logical_operator == "" do
nil
else
String.to_existing_atom(logical_operator)
after_fun = fn
[] -> {:cont, []}
acc -> {:cont, Enum.reverse(acc), []}
end

Enum.chunk_while(results, [], chunk_fun, after_fun)
end

def compile_results([], acc) do
acc
end

def compile_results([result | results], acc) do
group = Keyword.get(result, :grouping, nil)

acc =
if group do
result |> Keyword.to_list()

{logical_operator, _rest} = result |> Keyword.to_list() |> List.pop_at(1)

predicates =
group
|> Enum.chunk_every(4)
|> compile_results([])
|> Enum.reverse()

[
%Predicate{
predicates: predicates,
logical_operator: get_logical_operator([nil, nil, nil, logical_operator])
}
| acc
]
else
[
%Predicate{
condition: %Condition{
identifier: Keyword.get(result, :identifier),
comparison_operator: Keyword.get(result, :comparison_operator),
expression: Keyword.get(result, :expression)
},
logical_operator: get_logical_operator(result)
}
| acc
]
end

compile_results(results, acc)
end

def get_logical_operator([_, _, _, logical_operator]) when is_binary(logical_operator) do
logical_operator
|> String.downcase()
|> String.to_existing_atom()
end

def get_logical_operator(_) do
nil
end
end
50 changes: 40 additions & 10 deletions lib/predicated/query/parser.ex
@@ -1,6 +1,9 @@
defmodule Predicated.Query.Parser do
import NimbleParsec

lparen = ascii_char([?(]) |> label("(")
rparen = ascii_char([?)]) |> label(")")

whitespace = ascii_char([32, ?\t, ?\n]) |> times(min: 1) |> label("whitespace")

indentifier =
Expand All @@ -22,20 +25,47 @@ defmodule Predicated.Query.Parser do
|> reduce({Enum, :join, [""]})
|> unwrap_and_tag(:comparison_operator)

operator =
ignore(optional(whitespace))
|> optional(choice([string("AND"), string("and"), string("OR"), string("or")]))
|> reduce({Enum, :join, [""]})
|> unwrap_and_tag(:logical_operator)

predicate =
comparison =
ignore(optional(whitespace))
|> concat(indentifier)
|> concat(comparison_operator)
|> concat(expression)
|> concat(operator)

query = repeat(predicate) |> eos()
grouping =
empty()
|> choice([
ignore(lparen)
|> concat(parsec(:expr))
|> ignore(rparen)
|> tag(:grouping),
comparison
])

defcombinatorp(
:term,
empty()
|> choice([
grouping
|> ignore(optional(whitespace))
|> choice([string("AND"), string("and")])
|> ignore(optional(whitespace))
|> concat(parsec(:term)),
grouping
])
)

defcombinatorp(
:expr,
empty()
|> choice([
parsec(:term)
|> ignore(optional(whitespace))
|> choice([string("OR"), string("or")])
|> ignore(optional(whitespace))
|> concat(parsec(:expr)),
parsec(:term)
])
)

defparsec(:parse, query)
defparsec(:parse, parsec(:expr), export_metadata: true)
end
194 changes: 192 additions & 2 deletions test/predicated/query/parser_test.exs
Expand Up @@ -33,6 +33,43 @@ defmodule Predicated.QueryTest do
} == results
end

test "parses a query with a multiple logical operator" do
results = Query.new("trace_id == 'test123' and profile_id == '123' or user_id == '123'")

assert {
:ok,
[
%Predicate{
condition: %Condition{
identifier: "trace_id",
comparison_operator: "==",
expression: "test123"
},
logical_operator: :and,
predicates: []
},
%Predicate{
condition: %Condition{
identifier: "profile_id",
comparison_operator: "==",
expression: "123"
},
logical_operator: :or,
predicates: []
},
%Predicate{
condition: %Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "123"
},
logical_operator: nil,
predicates: []
}
]
} == results
end

test "parses a query without a logical operator" do
results = Query.new("trace_id == 'test123'")

Expand All @@ -52,16 +89,169 @@ defmodule Predicated.QueryTest do
} == results
end

test "parses a query with grouped predictes" do
results = Query.new("trace_id == 'test123' OR (user_id == '123' OR user_id == '456')")

assert {:ok,
[
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "trace_id",
comparison_operator: "==",
expression: "test123"
},
logical_operator: :or,
predicates: []
},
%Predicated.Predicate{
condition: nil,
logical_operator: nil,
predicates: [
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "123"
},
logical_operator: :or,
predicates: []
},
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "456"
},
logical_operator: nil,
predicates: []
}
]
}
]} == results
end

test "parses a query with grouped predictes and a predicate that follows a grouped predicate" do
results =
Query.new(
"trace_id == 'test123' AND (user_id == '123' OR user_id == '456') OR organization_id == '1'"
)

assert {:ok,
[
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "trace_id",
comparison_operator: "==",
expression: "test123"
},
logical_operator: :and,
predicates: []
},
%Predicated.Predicate{
condition: nil,
logical_operator: :or,
predicates: [
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "123"
},
logical_operator: :or,
predicates: []
},
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "456"
},
logical_operator: nil,
predicates: []
}
]
},
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "organization_id",
comparison_operator: "==",
expression: "1"
},
logical_operator: nil,
predicates: []
}
]} == results
end

test "parses a query with nested grouped predictes" do
results =
Query.new(
"trace_id == 'test123' AND ( organization_id == '1' OR (user_id == '123' OR user_id == '456'))"
)

assert {:ok,
[
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "trace_id",
comparison_operator: "==",
expression: "test123"
},
logical_operator: :and,
predicates: []
},
%Predicated.Predicate{
condition: nil,
logical_operator: nil,
predicates: [
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "organization_id",
comparison_operator: "==",
expression: "1"
},
logical_operator: :or,
predicates: []
},
%Predicated.Predicate{
condition: nil,
logical_operator: nil,
predicates: [
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "123"
},
logical_operator: :or,
predicates: []
},
%Predicated.Predicate{
condition: %Predicated.Condition{
identifier: "user_id",
comparison_operator: "==",
expression: "456"
},
logical_operator: nil,
predicates: []
}
]
}
]
}
]} == results
end

test "handles an empty string" do
results = Query.new("")

assert {:ok, []} == results
assert {:error, _} = results
end

test "handles an invalid query string" do
results = Query.new("lakjsdlkj")

assert {:error, "expected end of string"} == results
assert {:error, _} = results
end
end
end

0 comments on commit cca03c4

Please sign in to comment.