Skip to content

Instantly share code, notes, and snippets.

@fmcgeough
Last active October 20, 2018 18:25
Show Gist options
  • Select an option

  • Save fmcgeough/47e4afd78ce03a4328a90fc9917df1f2 to your computer and use it in GitHub Desktop.

Select an option

Save fmcgeough/47e4afd78ce03a4328a90fc9917df1f2 to your computer and use it in GitHub Desktop.
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