%%% @doc Query Planner and Prepared Statement Execution
%%%
%%% Provides a Lisp-inspired tuple-based DSL for constructing and executing
%%% relational algebra queries. Queries are represented as nested Erlang tuples
%%% that can be validated, optimized, and compiled into iterator pipelines.
%%%
%%% == Query DSL Syntax ==
%%%
%%% The DSL uses nested tuples to represent relational operations:
%%%
%%% ```
%%% % Scan relation (base case)
%%% {scan, employees}
%%%
%%% % Reference relation (synonym for scan)
%%% {relation, employees}
%%%
%%% % Selection (filter)
%%% {select, {scan, employees}, fun(T) -> maps:get(age, T) > 30 end}
%%%
%%% % Projection
%%% {project, {scan, employees}, [name, age]}
%%%
%%% % Equijoin (natural join on common attribute)
%%% {join, {scan, employees}, {scan, departments}, dept_id}
%%%
%%% % Theta join (join with arbitrary predicate)
%%% {theta_join,
%%% {scan, employees},
%%% {scan, departments},
%%% fun(L, R) -> maps:get(dept_id, L) =:= maps:get(id, R) end}
%%%
%%% % Sort
%%% {sort, {scan, employees}, fun(A, B) -> maps:get(age, A) =< maps:get(age, B) end}
%%%
%%% % Take (limit)
%%% {take, {scan, employees}, 10}
%%%
%%% % Rename attribute
%%% {rename, old_name, new_name, {scan, employees}}
%%%
%%% % Composite query
%%% {project,
%%% {select,
%%% {join,
%%% {scan, employees},
%%% {scan, departments},
%%% dept_id},
%%% fun(T) -> maps:get(age, T) > 30 end},
%%% [name, dept_name]}
%%% ```
%%%
%%% == Usage ==
%%%
%%% ```
%%% % Prepare a query plan
%%% Plan = query_planner:prepare({select, {scan, employees},
%%% fun(T) -> maps:get(age, T) > 30 end}).
%%%
%%% % Execute and get iterator
%%% Iter = query_planner:execute(DB, Plan).
%%%
%%% % Collect all results
%%% Results = query_planner:collect(DB, Plan).
%%% ```
%%%
%%% @author Nekoma Team
%%% @copyright 2025
-module(query_planner).
-export([prepare/1,
execute/2,
collect/2,
explain/1]).
-include("operations.hrl").
%%% Types
-type query_plan() ::
{scan, atom()} % Scan relation
| {relation, atom()} % Reference relation
| {select, query_plan(), fun((map()) -> boolean())} % Filter
| {project, query_plan(), [atom()]} % Projection
| {join, query_plan(), query_plan(), atom()} % Equijoin
| {theta_join, query_plan(), query_plan(),
fun((map(), map()) -> boolean())} % Theta join
| {sort, query_plan(), fun((map(), map()) -> boolean())} % Sort
| {take, query_plan(), pos_integer()} % Limit
| {rename, atom(), atom(), query_plan()} % Rename attribute
| {materialize, query_plan()} % Materialize all
| {materialize, query_plan(), pos_integer()}. % Materialize N tuples
-export_type([query_plan/0]).
%%% Public API
%% @doc Prepare and validate a query plan.
%%
%% Takes a query plan tuple and validates its structure. Returns the
%% plan unchanged if valid, or throws an error if invalid.
%%
%% @param Plan The query plan to prepare
%% @returns The validated query plan
%% @throws {invalid_plan, Reason}
-spec prepare(query_plan()) -> query_plan().
prepare(Plan) ->
case validate_plan(Plan) of
ok -> Plan;
{error, Reason} -> error({invalid_plan, Reason})
end.
%% @doc Execute a prepared query plan against a database.
%%
%% Compiles the query plan into an iterator pipeline and returns
%% the iterator handle. The caller must consume the iterator using
%% operations:next_tuple/1 and close it with operations:close_iterator/1.
%%
%% @param DB The database state
%% @param Plan The prepared query plan
%% @returns Iterator process PID
-spec execute(term(), query_plan()) -> pid().
execute(DB, Plan) ->
compile_to_iterator(DB, Plan).
%% @doc Execute a query plan and collect all results.
%%
%% Convenience function that executes the plan and collects all
%% tuples into a list. Automatically closes the iterator.
%%
%% If the iterator times out (e.g., when sorting an infinite relation),
%% returns partial results with a warning.
%%
%% @param DB The database state
%% @param Plan The prepared query plan
%% @returns List of result tuples, or partial results on timeout
-spec collect(term(), query_plan()) -> [map()].
collect(DB, Plan) ->
Iter = execute(DB, Plan),
Results = operations:collect_all(Iter),
operations:close_iterator(Iter),
case Results of
{error, timeout, PartialResults} ->
io:format("~nWarning: Query timed out. Returning partial results (~b tuples).~n",
[length(PartialResults)]),
io:format("Tip: For infinite relations, use 'take' before operations like 'sort'.~n~n"),
PartialResults;
{error, Reason, PartialResults} ->
io:format("~nWarning: Query failed (~p). Returning partial results (~b tuples).~n~n",
[Reason, length(PartialResults)]),
PartialResults;
_ ->
Results
end.
%% @doc Explain a query plan in human-readable form.
%%
%% Converts the query plan to a readable string representation
%% showing the operation tree.
%%
%% @param Plan The query plan to explain
%% @returns String representation of the plan
-spec explain(query_plan()) -> string().
explain(Plan) ->
explain_plan(Plan, 0).
%%% Internal Functions
%% Validate query plan structure
-spec validate_plan(query_plan()) -> ok | {error, term()}.
validate_plan({scan, Name}) when is_atom(Name) ->
ok;
validate_plan({relation, Name}) when is_atom(Name) ->
ok;
validate_plan({select, SubPlan, Pred}) when is_function(Pred, 1) ->
validate_plan(SubPlan);
validate_plan({project, SubPlan, Attrs}) when is_list(Attrs) ->
case lists:all(fun is_atom/1, Attrs) of
true -> validate_plan(SubPlan);
false -> {error, {invalid_attributes, Attrs}}
end;
validate_plan({join, Left, Right, Attr}) when is_atom(Attr) ->
case {validate_plan(Left), validate_plan(Right)} of
{ok, ok} -> ok;
{{error, _} = Error, _} -> Error;
{_, {error, _} = Error} -> Error
end;
validate_plan({theta_join, Left, Right, Pred}) when is_function(Pred, 2) ->
case {validate_plan(Left), validate_plan(Right)} of
{ok, ok} -> ok;
{{error, _} = Error, _} -> Error;
{_, {error, _} = Error} -> Error
end;
validate_plan({sort, SubPlan, Comp}) when is_function(Comp, 2) ->
validate_plan(SubPlan);
validate_plan({take, SubPlan, N}) when is_integer(N), N > 0 ->
validate_plan(SubPlan);
validate_plan({rename, OldAttr, NewAttr, SubPlan}) when is_atom(OldAttr), is_atom(NewAttr) ->
validate_plan(SubPlan);
validate_plan({materialize, SubPlan}) ->
validate_plan(SubPlan);
validate_plan({materialize, SubPlan, N}) when is_integer(N), N > 0 ->
validate_plan(SubPlan);
validate_plan(Plan) ->
{error, {unknown_operator, Plan}}.
%% Compile query plan to iterator pipeline
-spec compile_to_iterator(term(), query_plan()) -> pid().
compile_to_iterator(DB, {scan, Name}) ->
operations:get_tuples_iterator(DB, Name);
compile_to_iterator(DB, {relation, Name}) ->
operations:get_tuples_iterator(DB, Name);
compile_to_iterator(DB, {select, SubPlan, Predicate}) ->
SubIter = compile_to_iterator(DB, SubPlan),
operations:select_iterator(SubIter, Predicate);
compile_to_iterator(DB, {project, SubPlan, Attributes}) ->
SubIter = compile_to_iterator(DB, SubPlan),
operations:project_iterator(SubIter, Attributes);
compile_to_iterator(DB, {join, LeftPlan, RightPlan, Attribute}) ->
LeftIter = compile_to_iterator(DB, LeftPlan),
RightIter = compile_to_iterator(DB, RightPlan),
operations:equijoin_iterator(LeftIter, RightIter, Attribute);
compile_to_iterator(DB, {theta_join, LeftPlan, RightPlan, Predicate}) ->
LeftIter = compile_to_iterator(DB, LeftPlan),
RightIter = compile_to_iterator(DB, RightPlan),
operations:theta_join_iterator(LeftIter, RightIter, Predicate);
compile_to_iterator(DB, {sort, SubPlan, Comparator}) ->
SubIter = compile_to_iterator(DB, SubPlan),
operations:sort_iterator(SubIter, Comparator);
compile_to_iterator(DB, {take, SubPlan, N}) ->
SubIter = compile_to_iterator(DB, SubPlan),
operations:take_iterator(SubIter, N);
compile_to_iterator(DB, {rename, OldAttr, NewAttr, SubPlan}) ->
SubIter = compile_to_iterator(DB, SubPlan),
rename_iterator(SubIter, OldAttr, NewAttr);
compile_to_iterator(DB, {materialize, SubPlan}) ->
% Materialize returns all tuples as a "static" iterator
SubIter = compile_to_iterator(DB, SubPlan),
Results = operations:collect_all(SubIter),
operations:close_iterator(SubIter),
static_iterator(Results);
compile_to_iterator(DB, {materialize, SubPlan, Limit}) ->
% Materialize with limit
SubIter = compile_to_iterator(DB, SubPlan),
LimitedIter = operations:take_iterator(SubIter, Limit),
Results = operations:collect_all(LimitedIter),
operations:close_iterator(LimitedIter),
static_iterator(Results).
%% Static iterator - serves pre-materialized tuples
-spec static_iterator([map()]) -> pid().
static_iterator(Tuples) ->
spawn(fun() -> static_loop(Tuples) end).
static_loop(Tuples) ->
receive
{next, Caller} ->
case Tuples of
[] ->
Caller ! done,
static_loop([]);
[Tuple | Rest] ->
Caller ! {tuple, Tuple},
static_loop(Rest)
end;
{close, Caller} ->
Caller ! ok
end.
%% Rename iterator - renames an attribute in each tuple
-spec rename_iterator(pid(), atom(), atom()) -> pid().
rename_iterator(SourceIter, OldAttr, NewAttr) ->
spawn(fun() -> rename_loop(SourceIter, OldAttr, NewAttr) end).
rename_loop(SourceIter, OldAttr, NewAttr) ->
receive
{next, Caller} ->
case operations:next_tuple(SourceIter) of
done ->
Caller ! done,
rename_loop(SourceIter, OldAttr, NewAttr);
{ok, Tuple} ->
RenamedTuple = case maps:is_key(OldAttr, Tuple) of
true ->
Value = maps:get(OldAttr, Tuple),
Tuple1 = maps:remove(OldAttr, Tuple),
maps:put(NewAttr, Value, Tuple1);
false ->
Tuple
end,
Caller ! {tuple, RenamedTuple},
rename_loop(SourceIter, OldAttr, NewAttr)
end;
{close, Caller} ->
operations:close_iterator(SourceIter),
Caller ! ok
end.
%% Pretty-print query plan
-spec explain_plan(query_plan(), non_neg_integer()) -> string().
explain_plan({scan, Name}, Indent) ->
indent(Indent) ++ "Scan: " ++ atom_to_list(Name) ++ "\n";
explain_plan({relation, Name}, Indent) ->
indent(Indent) ++ "Relation: " ++ atom_to_list(Name) ++ "\n";
explain_plan({select, SubPlan, _Pred}, Indent) ->
indent(Indent) ++ "Select (filter)\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({project, SubPlan, Attrs}, Indent) ->
AttrStr = string:join([atom_to_list(A) || A <- Attrs], ", "),
indent(Indent) ++ "Project [" ++ AttrStr ++ "]\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({join, LeftPlan, RightPlan, Attr}, Indent) ->
indent(Indent) ++ "Join on " ++ atom_to_list(Attr) ++ "\n" ++
explain_plan(LeftPlan, Indent + 2) ++
explain_plan(RightPlan, Indent + 2);
explain_plan({theta_join, LeftPlan, RightPlan, _Pred}, Indent) ->
indent(Indent) ++ "Theta Join\n" ++
explain_plan(LeftPlan, Indent + 2) ++
explain_plan(RightPlan, Indent + 2);
explain_plan({sort, SubPlan, _Comp}, Indent) ->
indent(Indent) ++ "Sort\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({take, SubPlan, N}, Indent) ->
indent(Indent) ++ "Take " ++ integer_to_list(N) ++ "\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({rename, OldAttr, NewAttr, SubPlan}, Indent) ->
indent(Indent) ++ "Rename " ++ atom_to_list(OldAttr) ++ " -> " ++ atom_to_list(NewAttr) ++ "\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({materialize, SubPlan}, Indent) ->
indent(Indent) ++ "Materialize (all)\n" ++
explain_plan(SubPlan, Indent + 2);
explain_plan({materialize, SubPlan, N}, Indent) ->
indent(Indent) ++ "Materialize (" ++ integer_to_list(N) ++ " tuples)\n" ++
explain_plan(SubPlan, Indent + 2).
%% Helper: indent string
-spec indent(non_neg_integer()) -> string().
indent(N) -> lists:duplicate(N, $\s).