From cca03c4e15206cdb7d1bf409f4d9c322b8106c29 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Mon, 4 Dec 2023 08:03:14 -0500 Subject: [PATCH] adding support for nesting and grouping (#2) * adding support for nesting and grouping * cleaning up --- lib/predicated/predicate.ex | 2 +- lib/predicated/query.ex | 90 +++++++++--- lib/predicated/query/parser.ex | 50 +++++-- test/predicated/query/parser_test.exs | 194 +++++++++++++++++++++++++- 4 files changed, 301 insertions(+), 35 deletions(-) diff --git a/lib/predicated/predicate.ex b/lib/predicated/predicate.ex index bfa17e8..235942b 100644 --- a/lib/predicated/predicate.ex +++ b/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 diff --git a/lib/predicated/query.ex b/lib/predicated/query.ex index 335d65f..107e56a 100644 --- a/lib/predicated/query.ex +++ b/lib/predicated/query.ex @@ -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} @@ -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 diff --git a/lib/predicated/query/parser.ex b/lib/predicated/query/parser.ex index 7c6ced2..1439900 100644 --- a/lib/predicated/query/parser.ex +++ b/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 = @@ -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 diff --git a/test/predicated/query/parser_test.exs b/test/predicated/query/parser_test.exs index a45d6ff..6ab1bef 100644 --- a/test/predicated/query/parser_test.exs +++ b/test/predicated/query/parser_test.exs @@ -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'") @@ -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