defmodule Selecto do
defstruct [:repo, :domain, :config, :set]
import Ecto.Query
@moduledoc """
Documentation for `Selecto,` a query writer and report generator for Elixir/Ecto
TODO
having
json/embeds/arrays/maps?
json: tablen[field].somejsonkey tablen[field][index].somekey...
distinct
select into tuple or list instead of map more efficient?
ability to add synthetic root, joins, filters, columns
union, union all, intersect, intersect all
-- pass in lists of alternative filters
-- allow multiple unions
limit, offset
subqueries
Mebbie:
windows?
CTEs? recursive?
first, last?? as limit, reverse_order
ERROR CHECKS
-- Has association by right name?
"""
@doc """
Generate a selecto structure from this Repo following
the instructinos in Domain map
"""
def configure(repo, domain) do
%Selecto{
repo: repo,
domain: domain,
config: configure_domain(domain),
set: %{
selected: Map.get(domain, :required_selected, []),
filtered: [],
order_by: Map.get(domain, :required_order_by, []),
group_by: Map.get(domain, :required_group_by, [])
}
}
end
# generate the selecto configuration
defp configure_domain(%{source: source} = domain) do
primary_key = source.__schema__(:primary_key)
fields =
Selecto.Schema.Column.configure_columns(
:selecto_root,
## Add in keys from domain.columns ...
source.__schema__(:fields) -- source.__schema__(:redact_fields),
source,
domain
)
joins = Selecto.Schema.Join.recurse_joins(source, domain)
## Combine fields from Joins into fields list
fields =
List.flatten([fields | Enum.map(Map.values(joins), fn e -> e.fields end)])
|> Enum.reduce(%{}, fn m, acc -> Map.merge(m, acc) end)
### Extra filters (all normal fields can be a filter)
filters = Map.get(domain, :filters, %{})
filters =
Enum.reduce(
Map.values(joins),
filters,
fn e, acc ->
Map.merge(Map.get(e, :filters, %{}), acc)
end
)
%{
primary_key: primary_key,
columns: fields,
joins: joins,
filters: filters,
domain_data: Map.get(domain, :domain_data)
}
end
@doc """
add a field to the Select list. Send in one or a list of field names or selectable tuples
TODO allow to send single, and special forms..
"""
def select(selecto, fields) when is_list(fields) do
put_in(selecto.set.selected, Enum.uniq(selecto.set.selected ++ fields))
end
def select(selecto, field) do
Selecto.select(selecto, [field])
end
#### Selects
### Add parameterized select functions...
defp check_string( string ) do
if string |> String.match?(~r/^[^a-zA-Z0-9_]+$/) do
raise "Invalid String #{string}"
end
string
end
## need more? upper, lower, ???, postgres specifics?
defp apply_selection({query, aliases}, config, {:subquery, func, field}) do
conf = config.columns[field]
join = config.joins[conf.requires_join]
my_func = check_string( Atom.to_string(func) )
my_key = Atom.to_string(join.my_key)
my_field = Atom.to_string(conf.field)
# from a in SelectoTest.Test.SolarSystem, select: {fragment("(select json_agg(planets) from planets where solar_system_id = ?)", a.id)}
# from a in SelectoTest.Test.SolarSystem, select: {fragment("(select count(id) from planets where solar_system_id = ?)", a.id)}
as = "#{func}(#{field})"
dyn = %{
as =>
dynamic(
[{^join.requires_join, par}],
fragment(
"(select ?(?) from ? where ? = ?)",
literal(^my_func),
literal(^my_field),
literal(^join.source),
literal(^my_key),
par.id
)
)
}
query = from(a in query, select_merge: ^dyn)
{query, [as | aliases]}
end
# ARRAY - auto gen array from otherwise denorm'ing selects using postgres 'array' func
# ---- eg {"array", "item_orders", select: ["item[name]", "item_orders[quantity]"], filters: [{item[type], "Pin"}]}
# ---- postgres has functions to put those into json!
# to select the items into an array and apply the filter to the subq. Would ahve to be something that COULD join
# to one of the main query joins
#TODOs
# defp apply_selection({query, aliases}, _config, {:array, _field, _selects}) do
# {query, aliases}
# end
# # COALESCE ... ??
# defp apply_selection({query, aliases}, _config, {:coalesce, _field, _selects}) do
# {query, aliases}
# end
# # CASE ... {:case, %{{...filter...}}=>val, cond2=>val, :else=>val}}
# defp apply_selection({query, aliases}, _config, {:case, _field, _case_map}) do
# {query, aliases}
# end
defp apply_selection({query, aliases}, config, {:extract, field, format}) do
conf = config.columns[field]
as = "#{format} from #{field}"
check_string(format)
query =
from({^conf.requires_join, owner} in query,
select_merge: %{
^"#{as}" => fragment("extract(? from ?)", literal(^format), field(owner, ^conf.field))
}
)
{query, [as | aliases]}
end
defp apply_selection({query, aliases}, config, {:to_char, {field, format}, as}) do
conf = config.columns[field]
query =
from({^conf.requires_join, owner} in query,
select_merge: %{
^"#{as}" => fragment("to_char(?, ?)", field(owner, ^conf.field), ^format)
}
)
{query, [as | aliases]}
end
## Todo why this does not work with numbers?
defp apply_selection({query, aliases}, _config, {:literal, name, value}) do
query = from({:selecto_root, owner} in query, select_merge: %{^name => ^value})
{query, [name | aliases]}
end
### works with any func/agg of normal form
defp apply_selection({query, aliases}, config, {func, field}) when is_atom(func) do
use_as = "#{func}(#{field})"
apply_selection({query, aliases}, config, {func, field, use_as})
end
## Case of literal value arg
defp apply_selection({query, aliases}, _config, {func, {:literal, field}, as})
when is_atom(func) do
func = Atom.to_string(func) |> check_string()
query =
from(query,
select_merge: %{
^"#{as}" => fragment("?(?)", literal(^func), ^field)
}
)
{query, [as | aliases]}
end
# Case for func call with field as arg
## Check for SQL INJ TODO
## TODO allow for func call args
## TODO variant for 2 arg aggs eg string_agg, jsonb_object_agg, Grouping
## ^^ and mixed lit/field args - field as list?
defp apply_selection({query, aliases}, config, {func, field, as}) when is_atom(func) do
conf = config.columns[field]
func = Atom.to_string(func) |> check_string()
query =
from({^conf.requires_join, owner} in query,
select_merge: %{
^"#{as}" => fragment("?(?)", literal(^func), field(owner, ^conf.field))
}
)
{query, [as | aliases]}
end
# Case of 'count(*)' which we can just ref as count
defp apply_selection({query, aliases}, _config, {:count}) do
query = from(query, select_merge: %{"count" => fragment("count(*)")})
{query, ["count" | aliases]}
end
# case of other non-arg funcs eg now()
defp apply_selection({query, aliases}, _config, {func}) when is_atom(func) do
func = Atom.to_string(func) |> check_string()
from(query, select_merge: %{^func => fragment("?()", literal(^func))})
{query, [func | aliases]}
end
### regular old fields. Allow atoms?
defp apply_selection({query, aliases}, config, field) when is_binary(field) do
conf = config.columns[field]
query =
from({^conf.requires_join, owner} in query,
select_merge: %{^field => field(owner, ^conf.field)}
)
{query, [field | aliases]}
end
### applies the selections to the query
defp apply_selections(query, config, selected) do
{query, aliases} =
selected
|> Enum.reduce({query, []}, fn s, acc ->
apply_selection(acc, config, s)
end)
{query, Enum.reverse(aliases)}
end
# get a map of joins to list of selected
defp joins_from_selects(fields, selected) do
selected
|> Enum.map(fn
{:array, _n, sels} -> sels
{:coalesce, _n, sels} -> sels
{:case, _n, case_map} -> Map.values(case_map)
{:literal, _a, _b} -> []
{_f, {s, _d}, _p} -> s
{_f, s, _p} -> s
{_f, s} -> s
{_f} -> nil
s -> s
end)
|> List.flatten()
|> Enum.filter(fn
{:literal, _s} -> false
s -> not is_nil(s) and Map.get(fields, s)
end)
|> Enum.reduce(%{}, fn e, acc ->
Map.put(acc, fields[e].requires_join, 1)
end)
|> Map.keys()
end
@doc """
add a filter to selecto. Send in a tuple with field name and filter value
"""
def filter(selecto, filters) when is_list(filters) do
put_in(selecto.set.filtered, selecto.set.filtered ++ filters)
end
def filter(selecto, filters) do
put_in(selecto.set.filtered, selecto.set.filtered ++ [filters])
end
# Thanks to https://medium.com/swlh/how-to-write-a-nested-and-or-query-using-elixirs-ecto-library-b7755de79b80
defp combine_fragments_with_and(fragments) do
conditions = false
Enum.reduce(fragments, conditions, fn fragment, conditions ->
if !conditions do
dynamic([q], ^fragment)
else
dynamic([q], ^conditions and ^fragment)
end
end)
end
defp combine_fragments_with_or(fragments) do
conditions = false
Enum.reduce(fragments, conditions, fn fragment, conditions ->
if !conditions do
dynamic([q], ^fragment)
else
dynamic([q], ^conditions or ^fragment)
end
end)
end
defp apply_filters(query, config, filters) do
filter =
Enum.map(filters, fn f ->
filters_recurse(config, f)
end)
|> combine_fragments_with_and()
query |> where(^filter)
end
defp filters_recurse(config, {:or, filters}) do
Enum.map(filters, fn f ->
filters_recurse(config, f)
end)
|> combine_fragments_with_or()
end
defp filters_recurse(config, {:and, filters}) do
Enum.map(filters, fn f ->
filters_recurse(config, f)
end)
|> combine_fragments_with_and()
end
### TODO add :not
defp filters_recurse(config, {name, val}) do
def = config.columns[name]
table = def.requires_join
field = def.field
### how to allow function calls/subqueries in field and val?
case val do
x when is_nil(x) ->
dynamic([{^table, a}], is_nil(field(a, ^field)))
x when is_bitstring(x) or is_number(x) or is_boolean(x) ->
dynamic([{^table, a}], field(a, ^field) == ^val)
x when is_list(x) ->
dynamic([{^table, a}], field(a, ^field) in ^val)
# TODO not-in
# sucks to not be able to do these 6 in one with a fragment!
{x, v} when x == "!=" ->
dynamic([{^table, a}], field(a, ^field) != ^v)
{x, v} when x == "<" ->
dynamic([{^table, a}], field(a, ^field) < ^v)
{x, v} when x == ">" ->
dynamic([{^table, a}], field(a, ^field) > ^v)
{x, v} when x == "<=" ->
dynamic([{^table, a}], field(a, ^field) <= ^v)
{x, v} when x == ">=" ->
dynamic([{^table, a}], field(a, ^field) >= ^v)
{:between, min, max} ->
dynamic([{^table, a}], fragment("? between ? and ?", field(a, ^field), ^min, ^max))
:not_true ->
dynamic([{^table, a}], not field(a, ^field))
{:like, v} ->
dynamic([{^table, a}], like(field(a, ^field), ^v))
{:ilike, v} ->
dynamic([{^table, a}], ilike(field(a, ^field), ^v))
_ -> raise "Filter Recurse not implemented for #{inspect(val)}"
# date shortcuts (:today, :tomorrow, :last_week, etc )
# {:case, %{filter=>}}
# {:exists, etc-, subq} # how to do subq????
end
end
# Can only give us the joins.. make this recurse and handle :or, :and, etc
defp joins_from_filters(config, filters) do
filters
|> Enum.reduce(%{}, fn
{:or, list}, acc ->
Map.merge(
acc,
Enum.reduce(joins_from_filters(config, list), %{}, fn i, acc -> Map.put(acc, i, 1) end)
)
{:and, list}, acc ->
Map.merge(
acc,
Enum.reduce(joins_from_filters(config, list), %{}, fn i, acc -> Map.put(acc, i, 1) end)
)
{fil, _val}, acc ->
Map.put(acc, config.columns[fil].requires_join, 1)
end)
|> Map.keys()
end
@doc """
Add to the Order By
"""
def order_by(selecto, orders) do
put_in(selecto.set.order_by, selecto.set.order_by ++ orders)
end
defp apply_order_by(query, config, order_bys) do
order_bys =
order_bys
|> Enum.map(fn
{dir, field} -> {dir, field}
field -> {:asc_nulls_first, field}
end)
|> Enum.map(fn
{dir, field} ->
{dir,
dynamic(
[{^config.columns[field].requires_join, owner}],
field(owner, ^config.columns[field].field)
)}
end)
from(query,
order_by: ^order_bys
)
end
@doc """
Add to the Group By
"""
def group_by(selecto, groups) do
put_in(selecto.set.group_by, selecto.set.group_by ++ groups)
end
# From Ecto.OLAP Copyright (c) 2017 Łukasz Jan Niemier THANKS!
defmacro rollup(columns), do: mkquery(columns, "ROLLUP")
defp mkquery(data, name) do
quote do: fragment(unquote(name <> " ?"), unquote(fragment_list(data)))
end
defp fragment_list(list) when is_list(list) do
query = "?" |> List.duplicate(Enum.count(list)) |> Enum.join(",")
quote do: fragment(unquote("(" <> query <> ")"), unquote_splicing(list))
end
###
defp apply_group_by(query, _config, [], _) do
query
end
defp recurse_group_by(config, group_by) do
## Todo make these use 1, 2, 3 etc when possible
case group_by do
{:extract, field, format} ->
check_string(format)
dynamic(
[{^config.columns[field].requires_join, owner}],
fragment(
"extract( ? from ? )",
literal(^format),
field(owner, ^config.columns[field].field)
)
)
### how to dedupe?!?!
{:rollup, [a]} ->
dynamic([], rollup( [^recurse_group_by(config, a)] ) )
{:rollup, [a, b]} ->
dynamic([], rollup( [^recurse_group_by(config, a), ^recurse_group_by(config, b) ] ) )
{:rollup, [a, b, c]} ->
dynamic([], rollup( [^recurse_group_by(config, a), ^recurse_group_by(config, b),
^recurse_group_by(config, c) ] ) )
{:rollup, [a, b, c, d]} ->
dynamic([], rollup( [^recurse_group_by(config, a), ^recurse_group_by(config, b),
^recurse_group_by(config, c), ^recurse_group_by(config, d) ] ) )
field ->
dynamic(
[{^config.columns[field].requires_join, owner}],
field(owner, ^config.columns[field].field)
)
end
end
defp apply_group_by(query, config, group_bys, mode) do
group_bys =
group_bys |> Enum.map(fn g -> recurse_group_by(config, g) end)
case mode do
_ -> from(query, group_by: ^group_bys )
end
end
@doc """
Returns an Ecto.Query with all your filters and selections added..eventually!
"""
def gen_query(selecto, opts \\ []) do
IO.puts("Gen Query")
{group_by_type, opts} = Keyword.pop(opts, :group_by_type, :group)
joins_from_selects = joins_from_selects(selecto.config.columns, selecto.set.selected)
filters_to_use = Map.get(selecto.domain, :required_filters, []) ++ selecto.set.filtered
filtered_by_join = joins_from_filters(selecto.config, filters_to_use)
joins_from_order_by =
joins_from_selects(
selecto.config.columns,
Enum.map(selecto.set.order_by, fn
{_dir, field} -> field
field -> field
end)
)
joins_from_group_by = joins_from_selects(selecto.config.columns, selecto.set.group_by)
## We select nothing from the initial query because we are going to select_merge everything and
## if we don't select empty map here, it will include the full * of our source!
query = from(root in selecto.domain.source, as: :selecto_root, select: %{})
##### If we are GROUP BY and have AGGREGATES that live on a join path with any :many
##### cardinality we have to force the aggregates to subquery
{query, aliases} =
get_join_order(
selecto.config.joins,
Enum.uniq(
joins_from_selects ++ filtered_by_join ++ joins_from_order_by ++ joins_from_group_by
)
)
|> Enum.reduce(query, fn j, acc -> apply_join(selecto.config, acc, j) end)
|> apply_selections(selecto.config, selecto.set.selected)
IO.inspect(query, label: "Second Last")
query =
query
|> apply_filters(selecto.config, filters_to_use)
|> apply_group_by(selecto.config, selecto.set.group_by, group_by_type)
|> apply_order_by(selecto.config, selecto.set.order_by)
IO.inspect(query, label: "Last")
{query, aliases}
end
def gen_sql(selecto) do
#todo!
end
# apply the join to the query
# we don't need to join root!
defp apply_join(_config, query, :selecto_root) do
query
end
defp apply_join(config, query, join) do
join_map = config.joins[join]
from({^join_map.requires_join, par} in query,
left_join: b in ^join_map.i_am,
as: ^join,
on: field(par, ^join_map.owner_key) == field(b, ^join_map.my_key)
)
end
### We walk the joins pushing deps in front of joins recursively, then flatten and uniq to make final list
defp get_join_order(joins, requested_joins) do
requested_joins
|> Enum.map(fn j ->
case Map.get(joins, j, %{}) |> Map.get(:requires_join, nil) do
nil ->
j
req ->
[get_join_order(joins, [req]), req, j]
end
end)
|> List.flatten()
|> Enum.uniq()
end
@doc """
Generate and run the query, returning list of maps (for now...)
"""
def execute(selecto, opts \\ []) do
IO.puts("Execute Query")
{query, aliases} =
selecto
|> gen_query(opts)
IO.inspect(query, label: "Exe")
results =
query
|> selecto.repo.all()
|> IO.inspect(label: "Results")
{results, aliases}
end
def available_columns(selecto) do
selecto.config.columns
end
def available_filters(selecto) do
selecto.config.filters
end
end