184 lines
6.2 KiB
Elixir
184 lines
6.2 KiB
Elixir
defmodule FarmbotCore.Asset.CriteriaRetriever do
|
|
alias FarmbotCore.Asset.{ PointGroup, Repo, Point }
|
|
import Ecto.Query
|
|
|
|
@moduledoc """
|
|
The PointGroup asset declares a list
|
|
of criteria to query points. This
|
|
module then converts that criteria to
|
|
a list of real points that match the
|
|
criteria of a point group.
|
|
|
|
Example:
|
|
|
|
You have a PointGroup with a criteria
|
|
where group.criteria.number_gt.x == 10
|
|
Passing that PointGroup to this module
|
|
will return an array of `Point` assets
|
|
with an x property that is greater than
|
|
10.
|
|
"""
|
|
|
|
# We will not query any string/numeric fields other than these.
|
|
# Updating the PointGroup / Point models may require an update
|
|
# to these fields.
|
|
@numberic_fields ["radius", "x", "y", "z", "pullout_direction"]
|
|
@string_fields ["name", "openfarm_slug", "plant_stage", "pointer_type"]
|
|
|
|
@doc """
|
|
You provide it a %PointGroup{},
|
|
it provides you an array of
|
|
point records (%Point{}) that match
|
|
the group's criteria.
|
|
"""
|
|
def run(%PointGroup{point_ids: static_ids} = pg) do
|
|
# pg.point_ids is *always* include in search results,
|
|
# even if it does not match pg.criteria in any way.
|
|
always_ok = Repo.all(from(p in Point, where: p.id in ^static_ids, select: p))
|
|
# Now we need a list of point IDs that actually match
|
|
# the pg.criteria fields. We only get the ID because
|
|
# we are circumventing Ecto and doing raw SQL.
|
|
# There may be better ways to do this:
|
|
dynamic_ids = find_matching_point_ids(pg)
|
|
|
|
# Once we have a list of matching criteria, we can run a
|
|
# SQL query through Ecto to return real %Point{} structs...
|
|
dynamic_query = from(p in Point, where: p.id in ^dynamic_ids, select: p)
|
|
# ...but we're not done! If the criteria contains meta fields,
|
|
# we need to perform a lookup in memory
|
|
needs_meta_filter = Repo.all(dynamic_query)
|
|
# There we go. We have all the matching %Point{}s
|
|
search_matches = search_meta_fields(pg, needs_meta_filter)
|
|
# ...but there are duplicates. We can remove them via uniq_by:
|
|
Enum.uniq_by((search_matches ++ always_ok), fn p -> p.id end)
|
|
end
|
|
|
|
@doc """
|
|
Takes intermediate search results and makes them
|
|
more specific by iterating over the search results
|
|
and only returning the ones that match the meta.*
|
|
field provided in pg.criteria
|
|
"""
|
|
def search_meta_fields(%PointGroup{} = pg, points) do
|
|
meta = "meta."
|
|
meta_len = String.length(meta)
|
|
|
|
(pg.criteria["string_eq"] || %{})
|
|
|> Map.to_list()
|
|
|> Enum.filter(fn {k, _v} ->
|
|
String.starts_with?(k, meta)
|
|
end)
|
|
|> Enum.map(fn {k, value} ->
|
|
clean_key = String.slice(k, ((meta_len)..-1))
|
|
{clean_key, value}
|
|
end)
|
|
|> Enum.reduce(%{}, fn {key, value}, all ->
|
|
all_values = all[key] || []
|
|
Map.merge(all, %{key => value ++ all_values})
|
|
end)
|
|
|> Map.to_list()
|
|
|> Enum.reduce(points, fn {key, values}, finalists ->
|
|
finalists
|
|
|> Enum.filter(fn point -> point.meta[key] end)
|
|
|> Enum.filter(fn point -> point.meta[key] in values end)
|
|
end)
|
|
end
|
|
|
|
# Find all point IDs that are matched by a PointGroup
|
|
# This is not including any meta.* search terms,
|
|
# since `meta` is a JSON column that must be
|
|
# manually searched in memory.
|
|
defp find_matching_point_ids(%PointGroup{} = pg) do
|
|
results = {pg, []}
|
|
|> stage_1("string_eq", @string_fields, "IN")
|
|
|> stage_1("number_eq", @numberic_fields, "IN")
|
|
|> stage_1("number_gt", @numberic_fields, ">")
|
|
|> stage_1("number_lt", @numberic_fields, "<")
|
|
|> stage_1_day_field()
|
|
|> unwrap_stage_1()
|
|
|> Enum.reduce(%{}, &stage_2/2)
|
|
|> Map.to_list()
|
|
|> Enum.reduce({[], [], 0}, &stage_3/2)
|
|
|> unwrap_stage_3()
|
|
|> finalize()
|
|
|
|
results
|
|
end
|
|
|
|
# EDGE CASE: If the user _only_ wants to put static points
|
|
# in their group, we need to perform 0 SQL queries.
|
|
def finalize({_, []}) do
|
|
[]
|
|
end
|
|
|
|
def finalize({fragments, criteria}) do
|
|
x = Enum.join(fragments, " AND ")
|
|
sql = "SELECT id FROM points WHERE #{x}"
|
|
query_params = List.flatten(criteria)
|
|
{:ok, query} = Repo.query(sql, query_params)
|
|
%Sqlite.DbConnection.Result{ rows: rows } = query
|
|
List.flatten(rows)
|
|
end
|
|
|
|
defp unwrap_stage_1({_, right}), do: right
|
|
defp unwrap_stage_3({query, args, _count}), do: {query, args}
|
|
|
|
defp stage_1({pg, accum}, kind, fields, op) do
|
|
results = fields
|
|
|> Enum.map(fn field -> {field, pg.criteria[kind][field]} end)
|
|
|> Enum.filter(fn {_k, v} -> v end)
|
|
|> Enum.map(fn {k, v} -> {k, op, v} end)
|
|
{pg, accum ++ results}
|
|
end
|
|
|
|
defp stage_1_day_field({pg, accum}) do
|
|
day_criteria = pg.criteria["day"] || pg.criteria[:day] || %{}
|
|
days = day_criteria["days_ago"] || day_criteria[:days_ago] || 0
|
|
op = day_criteria["op"] || day_criteria[:op] || "<"
|
|
time = Timex.shift(Timex.now(), days: -1 * days)
|
|
|
|
if days == 0 do
|
|
{ pg, accum }
|
|
else
|
|
inverted_op = if op == ">" do "<" else ">" end
|
|
|
|
{ pg, accum ++ [{"created_at", inverted_op, time}] }
|
|
end
|
|
end
|
|
|
|
defp stage_2({lhs, "IN", rhs}, results) do
|
|
query = "#{lhs} IN"
|
|
all_values = results[query] || []
|
|
Map.merge(results, %{query => rhs ++ all_values})
|
|
end
|
|
|
|
defp stage_2({lhs, op, rhs}, results) do
|
|
Map.merge(results, %{"#{lhs} #{op}" => rhs})
|
|
end
|
|
|
|
# Interpolatinng data turned out to be hard.
|
|
# Pretty sure there is an easier way to do this.
|
|
# NOT OK: Repo.query("SELECT foo WHERE bar IN $0", [[1, 2, 3]])
|
|
# OK: Repo.query("SELECT foo WHERE bar IN ($0, $1, $2)", [1, 2, 3])
|
|
defp stage_3({sql, args}, {full_query, full_args, count0}) when is_list(args) do
|
|
arg_count = Enum.count(args)
|
|
final = count0 + (arg_count - 1)
|
|
initial_state = {sql, count0}
|
|
{next_sql, _} =
|
|
if arg_count == 1 do
|
|
{sql <> " ($#{count0})", nil}
|
|
else
|
|
Enum.reduce(args, initial_state, fn
|
|
(_, {sql, ^count0}) -> {sql <> " ($#{count0},", count0+1}
|
|
(_, {sql, ^final}) -> {sql <> " $#{final})", final}
|
|
(_, {sql, count}) -> {sql <> " $#{count},", count+1}
|
|
end)
|
|
end
|
|
{full_query ++ [next_sql], full_args ++ [args], final + 1}
|
|
end
|
|
|
|
defp stage_3({sql, args}, {full_query, full_args, count}) do
|
|
{full_query ++ [sql <> " $#{count}"], full_args ++ [args], count + 1}
|
|
end
|
|
end
|