Last active
October 20, 2018 18:25
-
-
Save fmcgeough/47e4afd78ce03a4328a90fc9917df1f2 to your computer and use it in GitHub Desktop.
This file contains hidden or 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 TagSearchElement do | |
| @moduledoc """ | |
| Helpers for building a Search definition | |
| a List or List of Lists of TagSearchElement are stored in the search | |
| column in the tag_searches table | |
| """ | |
| def new(key, comparison, value, connective \\ nil) | |
| when comparison in ["equal", "notequal", "like", "notlike"] and | |
| connective in [nil, "and", "or"] do | |
| %{ | |
| "key" => key, | |
| "comparison" => comparison, | |
| "value" => value, | |
| "connective" => connective | |
| } | |
| end | |
| @doc """ | |
| Ensure that an element has all required keys using pattern match | |
| """ | |
| def element_valid?(%{"key" => _, "comparison" => _, "value" => _, "connective" => _}) do | |
| true | |
| end | |
| def element_valid?(_element), do: false | |
| end | |
| defmodule QueryBuilder do | |
| @moduledoc """ | |
| Build an Ecto Query dynamically based on a List or List of Lists of TagSearchElements. | |
| Tag queries consist of TagSearchElements that define the key, value and comparision operator. | |
| If there are multiple TagSearchElement then elements are chained together by defining a | |
| connective operator ("and" or "or"). The connective defines how the current element connects | |
| with the previous element. That is: | |
| [ | |
| %{ | |
| "comparison" => "like", | |
| "connective" => nil, | |
| "key" => "Support", | |
| "value" => "Something" | |
| }, | |
| %{ | |
| "comparison" => "like", | |
| "connective" => "or", | |
| "key" => "Support", | |
| "value" => "Other String" | |
| } | |
| ] | |
| defines two TagSearchElements where the connective is "or" between them. | |
| Elements can also be logically grouped by providing them as a List within the input List. For | |
| example: | |
| [ | |
| [ | |
| %{ | |
| "comparison" => "like", | |
| "connective" => nil, | |
| "key" => "Support", | |
| "value" => "Something" | |
| }, | |
| %{ | |
| "comparison" => "like", | |
| "connective" => "or", | |
| "key" => "Support", | |
| "value" => "Other String" | |
| } | |
| ], | |
| [ | |
| %{ | |
| "comparison" => "equals", | |
| "connective" => "and", | |
| "key" => "DefaultKey", | |
| "value" => "MyGroup" | |
| } | |
| ] | |
| ] | |
| which becomes: (element1 or element2) and (element3) | |
| """ | |
| import Ecto.{Query}, warn: false | |
| @doc """ | |
| Build an Ecto Query for the module that defines the schema | |
| you're querying using a List or List of Lists of TagSearchElements. | |
| Returns {:ok, query} on success or {:error, "reason"} on failure | |
| Once you have the Ecto Query you can use it to select what you want | |
| or use that Query as a component of another Query. | |
| """ | |
| def build_query(module, elements) when is_list(elements) do | |
| case valid_element_list?(elements) do | |
| true -> {:ok, build_query(module, elements, has_blocks?(elements))} | |
| false -> {:error, "Element input is malformed"} | |
| end | |
| end | |
| def build_query(_module, _elements) do | |
| {:error, "Elements is not a list"} | |
| end | |
| @doc """ | |
| Can be used by external code to check to see if the built query list | |
| is ok | |
| """ | |
| def valid_element_list?(elements) do | |
| with true <- Enum.empty?(elements) == false, | |
| true <- levels_ok?(elements) do | |
| elements_valid?(elements) | |
| else | |
| _ -> false | |
| end | |
| end | |
| defp build_query(module, elements, true = _has_blocks) do | |
| elements | |
| |> Enum.map(fn element_list -> | |
| DynamicSearchBlock.build_dynamic_block(element_list, module) | |
| end) | |
| |> do_build_query(module) | |
| end | |
| defp build_query(module, elements, false = _has_blocks) do | |
| single_block = DynamicSearchBlock.build_dynamic_block(elements, module) | |
| do_build_query([single_block], module) | |
| end | |
| defp do_build_query(dynamics, module) do | |
| dynamics | |
| |> Enum.reduce(from(c in module), fn dynamic_block, query -> | |
| add_to_query(dynamic_block, query) | |
| end) | |
| end | |
| defp add_to_query({_block_connect, nil}, query), do: query | |
| defp add_to_query({block_connect, dynamic}, query) when block_connect in [nil, "and"] do | |
| from(c in query, where: ^dynamic) | |
| end | |
| defp add_to_query({block_connect, dynamic}, query) when block_connect in ["or"] do | |
| from(c in query, or_where: ^dynamic) | |
| end | |
| defp has_blocks?(elements) do | |
| case Enum.at(elements, 0) do | |
| nil -> false | |
| val -> is_list(val) | |
| end | |
| end | |
| defp elements_valid?(elements) do | |
| elements | |
| |> Enum.filter(fn element -> element_valid?(element) == false end) | |
| |> Enum.count() == 0 | |
| end | |
| defp element_valid?(element) when is_list(element) do | |
| elements_valid?(element) | |
| end | |
| # there are 4 required keys in each element | |
| defp element_valid?(element) do | |
| TagSearchElement.element_valid?(element) | |
| end | |
| def levels_ok?(elements) when is_list(elements) do | |
| elements | |
| |> Enum.filter(fn element -> block_contains_list?(element) end) | |
| |> Enum.count() == 0 | |
| end | |
| def levels_ok?(_elements), do: true | |
| defp block_contains_list?(elements) when is_list(elements) do | |
| elements | |
| |> Enum.filter(fn element -> is_list(element) end) | |
| |> Enum.count() != 0 | |
| end | |
| defp block_contains_list?(_), do: false | |
| end | |
| defmodule DynamicSearchBlock do | |
| @moduledoc """ | |
| Find the set of ids (primary keys) for the search specified | |
| in a Block for a particular Module and return it as an | |
| Ecto dynamic expression | |
| The assumption of this module is that you are querying a | |
| set of tags defined in a view for some AWS service. That view | |
| is identified as Module. The view has to define the | |
| columns: id (primary key for the actual table used for the service), | |
| key (tag name) and value (tag value). | |
| """ | |
| import Ecto.{Query}, warn: false | |
| alias AwsDetective.Repo | |
| @doc """ | |
| Build a list of ids that match the logic for the block where a block | |
| consists of: | |
| %{ | |
| "key" => key, | |
| "comparison" => comparison, | |
| "value" => value, | |
| "connective" => connective | |
| } | |
| """ | |
| def build_dynamic_block(elements, module) when is_list(elements) do | |
| block_connector = get_block_connector(elements) | |
| ids = build_id_list(elements, module) | |
| dynamic = dynamic([c], c.id in ^ids) | |
| {block_connector, dynamic} | |
| end | |
| defp build_id_list(elements, module) do | |
| elements | |
| |> Stream.with_index() | |
| |> Enum.reduce(MapSet.new(), fn {element, index}, acc -> | |
| process_element(acc, element, index, module) | |
| end) | |
| |> MapSet.to_list() | |
| end | |
| defp process_element( | |
| current_set, | |
| %{ | |
| "key" => key, | |
| "comparison" => comparison, | |
| "value" => value, | |
| "connective" => connective | |
| }, | |
| index, | |
| module | |
| ) do | |
| dynamic = build_clause(key, comparison, value) | |
| from(c in module, distinct: c.id, where: ^dynamic, select: c.id) | |
| |> Repo.all() | |
| |> Enum.reduce(MapSet.new(), fn id, acc -> MapSet.put(acc, id) end) | |
| |> process_set(current_set, index, connective) | |
| end | |
| defp process_set(new_set, _current_set, 0, _connective) do | |
| new_set | |
| end | |
| defp process_set(new_set, current_set, _index, "and") do | |
| MapSet.intersection(current_set, new_set) | |
| end | |
| defp process_set(new_set, current_set, _index, "or") do | |
| MapSet.union(current_set, new_set) | |
| end | |
| defp build_clause(key, "like", value) do | |
| dynamic([c], c.key == ^key and ilike(c.value, ^value)) | |
| end | |
| defp build_clause(key, "notlike", value) do | |
| dynamic([c], c.key == ^key and not ilike(c.value, ^value)) | |
| end | |
| defp build_clause(key, "equal", value) do | |
| dynamic([c], c.key == ^key and c.value == ^value) | |
| end | |
| defp build_clause(key, "notequal", value) do | |
| dynamic([c], c.key == ^key and c.value != ^value) | |
| end | |
| defp get_block_connector(elements) when is_list(elements) do | |
| Enum.at(elements, 0) | |
| |> case do | |
| nil -> nil | |
| element -> Map.get(element, "connective") | |
| end | |
| end | |
| defp get_block_connector(_elements), do: nil | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment