require Logger defmodule ExoSQL.Planner do @doc ~S""" Given a query, returns a tree of actions (AST) to perform to resolve the query. Each action is `{:plan, {step_function, step_data}}` to call. step_data may contain more tagged :plan to call recursively as required. They will be performed in reverse order and replaced where it is required. For example, it may return for a very simple: iex> {:ok, query} = ExoSQL.Parser.parse("SELECT name, price FROM products", %{"A" => {ExoSQL.Csv, path: "test/data/csv/"}}) iex> plan(query) {:ok, {:select, {:execute, {:table, {"A", "products"}}, [], [{"A", "products", "name"}, {"A", "products", "price"}]}, [ column: {"A", "products", "name"}, column: {"A", "products", "price"}] } } Or a more complex: iex> query = "SELECT users.name, products.name FROM users, purchases, products WHERE (users.id = purchases.user_id) AND (purchases.product_id = products.id)" iex> {:ok, query} = ExoSQL.Parser.parse(query, %{"A" => {ExoSQL.Csv, path: "test/data/csv/"}}) iex> plan(query) {:ok, {:select, {:filter, {:cross_join, {:cross_join, {:execute, {:table, {"A", "users"}}, [], [ {"A", "users", "id"}, {"A", "users", "name"} ]}, {:execute, {:table, {"A", "purchases"}}, [], [ {"A", "purchases", "user_id"}, {"A", "purchases", "product_id"} ]}}, {:execute, {:table, {"A", "products"}}, [], [ {"A", "products", "id"}, {"A", "products", "name"} ]} }, {:op, {"AND", {:op, {"=", {:column, {"A", "users", "id"}}, {:column, {"A", "purchases", "user_id"}}} }, {:op, {"=", {:column, {"A", "purchases", "product_id"}}, {:column, {"A", "products", "id"}}} }}}, }, [column: {"A", "users", "name"}, column: {"A", "products", "name"}] } } Which means that it will extract A.users, cross join with A.purchases, then cross join that with A.produtcs, apply a filter of the expession, and finally return only the users name and product name. TODO: explore different plans acording to some weights and return the optimal one. """ def plan(query) do where = ExoSQL.Expr.simplify(query.where, %{}) select = ExoSQL.Expr.simplify(query.select, %{}) all_expressions = [ where, select, query.groupby, query.from, query.join, Enum.map(query.orderby, fn {_type, expr} -> expr end), Enum.map(query.join, fn {:cross_join_lateral, q} -> q {_join, {_from, expr}} -> expr _other -> [] end) ] # Logger.debug("a All expressions: #{inspect query.from} | #{inspect all_expressions}") from = plan_execute(query.from, where, all_expressions) # Logger.debug("a Plan #{inspect from, pretty: true}") from_plan = if from == nil do # just one element %ExoSQL.Result{columns: ["?NONAME"], rows: [[1]]} else from end # Logger.debug("From plan #{inspect from} -> #{inspect from_plan}") join_plan = Enum.reduce(query.join, from_plan, fn {:cross_join, toplan}, acc -> # Logger.debug("b All expressions: #{inspect toplan} | #{inspect all_expressions}") from = plan_execute(toplan, all_expressions) # Logger.debug("b Plan #{inspect from, pretty: true}") {:cross_join, acc, from} {:cross_join_lateral, toplan}, acc -> from = plan_execute(toplan, all_expressions) {:cross_join_lateral, acc, from} {join_type, {toplan, expr}}, acc -> from = plan_execute(toplan, expr, all_expressions) {join_type, acc, from, expr} end) where_plan = if where do {:filter, join_plan, where} else join_plan end group_plan = if query.groupby do {:group_by, where_plan, query.groupby} else where_plan end # Order can be applied pre select or post select. This is the pre select. order_plan = query.orderby |> Enum.reverse |> Enum.reduce(group_plan, fn {_type, {:lit, _n}}, acc -> acc {type, expr}, acc -> {:order_by, type, expr, acc} end) select = Enum.map(select, fn {:select, query} -> {:ok, plan} = plan(query) {:select, plan} other -> other end) select_plan = cond do # if grouping, special care on aggregate builtins query.groupby -> selectg = Enum.map(select, &fix_aggregates_select(&1, Enum.count(query.groupby))) {:select, order_plan, selectg} # groups full table, do a table to row conversion, and then the ops has_aggregates(select) -> table_in_a_row = {:table_to_row, order_plan} selecta = Enum.map(select, &fix_aggregates_select(&1, 0)) {:select, table_in_a_row, selecta} true -> {:select, order_plan, select} end select_plan = if Enum.any?(select, fn {:fn, {name, _args}} -> ExoSQL.Builtins.is_projectable(name) {:lit, %{ columns: _, rows: _}} -> true {:alias, {{:lit, %{ columns: _, rows: _}}, _}} -> true {:alias, {{:fn, {name, _args}}, _}} -> ExoSQL.Builtins.is_projectable(name) other -> false end) do {:project, select_plan} else select_plan end distinct_plan = case query.distinct do nil -> select_plan other -> {:distinct, other, select_plan} end crosstab_plan = if query.crosstab do {:crosstab, query.crosstab, distinct_plan} else distinct_plan end # Order can be applied pre select or post select. This is the post select. order_plan = query.orderby |> Enum.reverse |> Enum.reduce(crosstab_plan, fn {type, {:lit, n}}, acc -> {:order_by, type, {:column, n - 1}, acc} {_type, _expr}, acc -> acc end) limit_plan = case query.offset do nil -> order_plan number -> {:offset, number, order_plan} end limit_plan = case query.limit do nil -> limit_plan number -> {:limit, number, limit_plan} end union_plan = case query.union do nil -> limit_plan {:distinct, other} -> {:ok, other_plan} = plan(other) { :distinct, :all_columns, {:union, limit_plan, other_plan} } {:all, other} -> {:ok, other_plan} = plan(other) {:union, limit_plan, other_plan} end with_plan = Enum.reduce(query.with, union_plan, fn {name, query}, prev_plan -> {:ok, plan} = plan(query) {:with, {name, plan}, prev_plan} end) plan = with_plan {:ok, plan} end def plan(plan, _context), do: plan(plan) defp plan_execute({:alias, {{:fn, {function, params}}, alias_}}, _where, _all_expressions) do ex = {:fn, {function, params}} {:alias, ex, alias_} end defp plan_execute({:alias, {{:table, {db, table}}, alias_}}, where, all_expressions) do columns = Enum.uniq(get_table_columns_at_expr(:tmp, alias_, all_expressions)) columns = Enum.map(columns, fn {:tmp, ^alias_, column} -> {db, table, column} end) quals = get_quals(:tmp, alias_, where) ex = {:execute, {:table, {db, table}}, quals, columns} {:alias, ex, alias_} end defp plan_execute({:alias, {%ExoSQL.Query{} = q, alias_}}, _where, _all_expressions) do {:ok, ex} = plan(q) {:alias, ex, alias_} end defp plan_execute({:table, {db, table}}, where, all_expressions) do columns = Enum.uniq(get_table_columns_at_expr(db, table, all_expressions)) quals = get_quals(db, table, where) {:execute, {:table, {db, table}}, quals, columns} end defp plan_execute(nil, _where, _all_expressions) do nil end defp plan_execute(%ExoSQL.Query{} = q, _where, _all_expressions) do {:ok, q} = plan(q) q end defp plan_execute({:fn, f}, _where, _all_expressions) do {:fn, f} end # this are with no _where defp plan_execute({:alias, {{:fn, {function, params}}, alias_}}, _all_expressions) do ex = {:fn, {function, params}} {:alias, ex, alias_} end defp plan_execute({:fn, _} = func, _all_expressions), do: func defp plan_execute({:table, {db, table}}, all_expressions) do columns = Enum.uniq(get_table_columns_at_expr(db, table, all_expressions)) {:execute, {:table, {db, table}}, [], columns} end defp plan_execute(%ExoSQL.Query{} = q, _all_expressions) do {:ok, q} = plan(q) q end ~S""" Gets all the vars referenced in an expression that refer to a given table Given a database and table, and an expression, return all columns from that {db, table} that are required by those expressions. This is used to know which columns to extract from the table. """ defp get_table_columns_at_expr(_db, _table, []) do [] end defp get_table_columns_at_expr(db, table, l) when is_list(l) do res = Enum.flat_map(l, &get_table_columns_at_expr(db, table, &1)) # Logger.debug("Get columns at table #{inspect {db, table}} at expr #{inspect l}: #{inspect res}") res end defp get_table_columns_at_expr(db, table, {:op, {_op, op1, op2}}) do get_table_columns_at_expr(db, table, op1) ++ get_table_columns_at_expr(db, table, op2) end defp get_table_columns_at_expr(db, table, {:column, {db, table, _var} = res}), do: [res] defp get_table_columns_at_expr(db, table, {:fn, {_f, params}}) do get_table_columns_at_expr(db, table, params) end defp get_table_columns_at_expr(db, table, {:alias, {expr, _alias}}) do get_table_columns_at_expr(db, table, expr) end defp get_table_columns_at_expr(db, table, {:select, query}) do res = get_table_columns_at_expr(db, table, [query.select, query.where, query.join]) # Logger.debug("Get parents #{inspect {db, table}} from #{inspect query, pretty: true}: #{inspect res}") res end defp get_table_columns_at_expr(db, table, {:case, list}) do Enum.flat_map(list, fn {e, v} -> Enum.flat_map([e, v], &get_table_columns_at_expr(db, table, &1)) {v} -> get_table_columns_at_expr(db, table, v) end) end defp get_table_columns_at_expr(db, table, {:distinct, expr}) do get_table_columns_at_expr(db, table, expr) end defp get_table_columns_at_expr(db, table, {:cross_join_lateral, expr}) do get_table_columns_at_expr(db, table, expr) end defp get_table_columns_at_expr(db, table, {:lateral, expr}) do get_table_columns_at_expr(db, table, expr) end defp get_table_columns_at_expr(_db, _table, _other) do [] end ~S""" If an aggregate function is found, rewrite it to be a real aggregate The way to do it is set as first argument the column with the aggregated table and the rest inside `{:pass, op}`, so its the real function that evaluates it over the first argument """ defp fix_aggregates_select({:op, {op, op1, op2}}, aggregate_column) do op1 = fix_aggregates_select(op1, aggregate_column) op2 = fix_aggregates_select(op2, aggregate_column) {:op, {op, op1, op2}} end defp fix_aggregates_select({:fn, {f, args}}, aggregate_column) do if ExoSQL.Builtins.is_aggregate(f) do args = for a <- args, do: {:pass, a} {:fn, {f, [{:column, aggregate_column} | args]}} else args = for a <- args, do: fix_aggregates_select(a, aggregate_column) {:fn, {f, args}} end end defp fix_aggregates_select({:alias, {expr, alias_}}, aggregate_column) do {:alias, {fix_aggregates_select(expr, aggregate_column), alias_}} end defp fix_aggregates_select(other, _) do other end defp has_aggregates({:op, {_op, op1, op2}}) do has_aggregates(op1) or has_aggregates(op2) end defp has_aggregates({:alias, {expr, _alias}}) do has_aggregates(expr) end defp has_aggregates({:fn, {f, args}}) do if not ExoSQL.Builtins.is_aggregate(f) do Enum.reduce(args, false, fn arg, acc -> acc or has_aggregates(arg) end) else true end end defp has_aggregates(l) when is_list(l), do: Enum.any?(l, &has_aggregates/1) defp has_aggregates(_other), do: false defp get_quals(db, table, expressions) when is_list(expressions) do Enum.flat_map(expressions, &get_quals(db, table, &1)) end defp get_quals(db, table, {:op, {op, {:column, {db, table, column}}, {:lit, value}}}) do [[column, op, value]] end defp get_quals(db, table, {:op, {op, {:lit, value}}, {:column, {db, table, column}}}) do [[column, op, value]] end defp get_quals(db, table, {:op, {op, {:column, {db, table, column}}, {:var, variable}}}) do [[column, op, {:var, variable}]] end defp get_quals(db, table, {:op, {op, {:var, variable}}, {:column, {db, table, column}}}) do [[column, op, {:var, variable}]] end defp get_quals(db, table, {:op, {"IN", {:column, {db, table, column}}, {:lit, list}}}) when is_list(list) do [[column, "IN", list]] end defp get_quals(db, table, {:op, {"AND", op1, op2}}) do Enum.flat_map([op1, op2], &get_quals(db, table, &1)) end defp get_quals(_db, _table, _expr), do: [] end