aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--rebar.config12
-rw-r--r--src/operations.erl17
-rw-r--r--src/query_planner.erl359
-rw-r--r--src/repl.erl333
-rw-r--r--src/visualizer.erl349
5 files changed, 1066 insertions, 4 deletions
diff --git a/rebar.config b/rebar.config
index e994dbf..0613ed8 100644
--- a/rebar.config
+++ b/rebar.config
@@ -7,7 +7,17 @@
{dialyzer, [
{plt_extra_apps, [merklet]},
- {warnings, [unknown]}
+ {warnings, [unknown]},
+ {exclude_mods, [
+ query_planner_test,
+ repl_comprehensive_test,
+ function_relations_test,
+ infinite_relations_test,
+ join_test,
+ operations_test,
+ provenance_test,
+ query_pipeline_test
+ ]}
]}.
{edoc_opts, [
diff --git a/src/operations.erl b/src/operations.erl
index d91eec9..36fb7e2 100644
--- a/src/operations.erl
+++ b/src/operations.erl
@@ -951,7 +951,10 @@ select_loop(ChildPid, Predicate) ->
end;
done ->
close_iterator(ChildPid),
- Caller ! done
+ Caller ! done;
+ {error, Reason} ->
+ close_iterator(ChildPid),
+ Caller ! {error, Reason}
end;
stop ->
close_iterator(ChildPid),
@@ -1008,7 +1011,10 @@ project_loop(ChildPid, Attributes) ->
project_loop(ChildPid, Attributes);
done ->
close_iterator(ChildPid),
- Caller ! done
+ Caller ! done;
+ {error, Reason} ->
+ close_iterator(ChildPid),
+ Caller ! {error, Reason}
end;
stop ->
close_iterator(ChildPid),
@@ -1092,7 +1098,10 @@ take_iter_loop(ChildPid, Limit, Count, OriginalN) when Count < Limit ->
take_iter_loop(ChildPid, Limit, Count + 1, OriginalN);
done ->
close_iterator(ChildPid),
- Caller ! done
+ Caller ! done;
+ {error, Reason} ->
+ close_iterator(ChildPid),
+ Caller ! {error, Reason}
end;
stop ->
close_iterator(ChildPid),
@@ -1343,6 +1352,7 @@ equijoin_emit_loop(LeftIter, RightTuples, JoinAttr) ->
end
end;
{error, Reason} ->
+ close_iterator(LeftIter),
Caller ! {error, Reason},
ok % Stop after sending error
end;
@@ -1396,6 +1406,7 @@ theta_join_emit_loop(LeftIter, RightTuples, Pred) ->
theta_join_emit_buffered(LeftIter, RightTuples, Pred, RestMatches)
end;
{error, Reason} ->
+ close_iterator(LeftIter),
Caller ! {error, Reason}
end;
stop ->
diff --git a/src/query_planner.erl b/src/query_planner.erl
new file mode 100644
index 0000000..e6f2324
--- /dev/null
+++ b/src/query_planner.erl
@@ -0,0 +1,359 @@
+%%% @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).
diff --git a/src/repl.erl b/src/repl.erl
new file mode 100644
index 0000000..572d17c
--- /dev/null
+++ b/src/repl.erl
@@ -0,0 +1,333 @@
+%%% @doc Interactive REPL Helper for Domino Query Planner
+%%%
+%%% Provides convenience functions for working with the query planner
+%%% in the Erlang shell (REPL). Includes utilities for pretty-printing
+%%% results and working with prepared statements.
+%%%
+%%% == Quick Start ==
+%%%
+%%% ```
+%%% % Start the system
+%%% repl:start().
+%%%
+%%% % Create a database with sample data
+%%% DB = repl:example_db().
+%%%
+%%% % Execute a query
+%%% repl:q(DB, {select, {relation, employees}, fun(T) -> maps:get(age, T) > 30 end}).
+%%%
+%%% % Explain a query plan
+%%% repl:explain({project, {relation, employees}, [name, age]}).
+%%% ```
+%%%
+%%% == Query Syntax Examples ==
+%%%
+%%% ```
+%%% % Scan relation
+%%% {scan, employees}
+%%%
+%%% % Filter
+%%% {select, {scan, employees}, fun(T) -> maps:get(age, T) > 30 end}
+%%%
+%%% % Project
+%%% {project, {scan, employees}, [name, age]}
+%%%
+%%% % Join
+%%% {join, {scan, employees}, {scan, departments}, dept_id}
+%%%
+%%% % Rename
+%%% {rename, age, years, {scan, employees}}
+%%%
+%%% % Complex pipeline
+%%% {take,
+%%% {sort,
+%%% {project,
+%%% {select,
+%%% {join,
+%%% {scan, employees},
+%%% {scan, departments},
+%%% dept_id},
+%%% fun(T) -> maps:get(age, T) > 30 end},
+%%% [name, dept_name, age]},
+%%% fun(A, B) -> maps:get(age, A) =< maps:get(age, B) end},
+%%% 5}
+%%% ```
+%%%
+%%% @author Nekoma Team
+%%% @copyright 2025
+
+-module(repl).
+
+-export([start/0,
+ example_db/0,
+ q/2,
+ q/3,
+ query/2,
+ show/1,
+ show/2,
+ explain/1,
+ pretty/1,
+ modes/0,
+ help/0]).
+
+-include("operations.hrl").
+
+%%% Public API
+
+%% @doc Start the Domino system and initialize Mnesia.
+%%
+%% Call this first when starting a new REPL session.
+%%
+%% @returns ok
+-spec start() -> ok.
+start() ->
+ operations:setup(),
+ io:format("~n~s~n", [banner()]),
+ io:format("Domino REPL started. Type repl:help() for usage.~n~n"),
+ ok.
+
+%% @doc Create an example database with sample data.
+%%
+%% Creates a database with employees and departments relations
+%% for testing and experimentation.
+%%
+%% @returns Database state
+-spec example_db() -> term().
+example_db() ->
+ DB = operations:create_database(example_db),
+
+ % Create employees relation
+ {DB1, _} = operations:create_relation(DB, employees, #{
+ id => integer,
+ name => string,
+ dept_id => integer,
+ age => integer,
+ salary => integer
+ }),
+
+ {DB2, _} = operations:create_tuple(DB1, employees, #{id => 1, name => "Alice", dept_id => 10, age => 30, salary => 80000}),
+ {DB3, _} = operations:create_tuple(DB2, employees, #{id => 2, name => "Bob", dept_id => 20, age => 35, salary => 90000}),
+ {DB4, _} = operations:create_tuple(DB3, employees, #{id => 3, name => "Carol", dept_id => 10, age => 28, salary => 75000}),
+ {DB5, _} = operations:create_tuple(DB4, employees, #{id => 4, name => "Dave", dept_id => 30, age => 42, salary => 95000}),
+ {DB6, _} = operations:create_tuple(DB5, employees, #{id => 5, name => "Eve", dept_id => 20, age => 25, salary => 70000}),
+
+ % Create departments relation
+ {DB7, _} = operations:create_relation(DB6, departments, #{
+ dept_id => integer,
+ dept_name => string,
+ budget => integer
+ }),
+
+ {DB8, _} = operations:create_tuple(DB7, departments, #{dept_id => 10, dept_name => "Engineering", budget => 100000}),
+ {DB9, _} = operations:create_tuple(DB8, departments, #{dept_id => 20, dept_name => "Sales", budget => 80000}),
+ {DB10, _} = operations:create_tuple(DB9, departments, #{dept_id => 30, dept_name => "Marketing", budget => 90000}),
+ {DB11, _} = operations:create_infinite_relation(DB10, #{
+ name => naturals,
+ schema => #{},
+ cardinality => aleph_zero,
+ generator => {primitive, naturals},
+ constraints => #{value => {gte, 0}}
+ }),
+
+ io:format("Example database created with:~n"),
+ io:format(" - employees (5 tuples)~n"),
+ io:format(" - departments (3 tuples)~n~n"),
+
+ DB11.
+
+%% @doc Execute a query and pretty-print results (default table mode).
+%%
+%% Convenience function that prepares, executes, and displays
+%% query results in a readable format. Returns `ok' to avoid
+%% duplicate output in the Erlang shell.
+%%
+%% If you need the results for further processing, use `query/2'.
+%%
+%% @param DB Database state
+%% @param Plan Query plan tuple
+%% @returns ok
+-spec q(term(), query_planner:query_plan()) -> ok.
+q(DB, Plan) ->
+ q(DB, Plan, table).
+
+%% @doc Execute a query with specified visualization mode.
+%%
+%% Displays query results using the specified visualization mode.
+%% Returns `ok' to avoid duplicate output in the Erlang shell.
+%%
+%% If you need the results for further processing, use `query/2'.
+%%
+%% @param DB Database state
+%% @param Plan Query plan tuple
+%% @param Mode Visualization mode (table, tree, nested_table, etc.)
+%% @returns ok
+-spec q(term(), query_planner:query_plan(), visualizer:render_mode()) -> ok.
+q(DB, Plan, Mode) ->
+ PreparedPlan = query_planner:prepare(Plan),
+ Results = query_planner:collect(DB, PreparedPlan),
+ visualizer:render(Results, Mode).
+
+%% @doc Execute a query without printing results.
+%%
+%% This function executes a query and returns the raw results without
+%% any visualization. Use this when you want to work with the results
+%% programmatically or decide later how to display them.
+%%
+%% @param DB Database state
+%% @param Plan Query plan tuple
+%% @returns List of result tuples
+%%
+%% == Example ==
+%% ```
+%% Results = repl:query(DB, {scan, employees}).
+%% FilteredResults = lists:filter(fun(T) -> maps:get(age, T) > 30 end, Results).
+%% repl:show(FilteredResults, tree).
+%% ```
+-spec query(term(), query_planner:query_plan()) -> [map()].
+query(DB, Plan) ->
+ PreparedPlan = query_planner:prepare(Plan),
+ query_planner:collect(DB, PreparedPlan).
+
+%% @doc Display results using default visualization mode (table).
+%%
+%% Takes a list of tuples (e.g., from `query/2`) and displays them
+%% using the table visualization mode.
+%%
+%% @param Results List of result tuples
+%% @returns ok
+-spec show([map()]) -> ok.
+show(Results) ->
+ show(Results, table).
+
+%% @doc Display results using specified visualization mode.
+%%
+%% Takes a list of tuples (e.g., from `query/2`) and displays them
+%% using the specified visualization mode.
+%%
+%% @param Results List of result tuples
+%% @param Mode Visualization mode (table, tree, nested_table, etc.)
+%% @returns ok
+%%
+%% == Example ==
+%% ```
+%% Results = repl:query(DB, {scan, employees}),
+%% repl:show(Results, tree).
+%% ```
+-spec show([map()], visualizer:render_mode()) -> ok.
+show(Results, Mode) ->
+ visualizer:render(Results, Mode).
+
+%% @doc Explain a query plan.
+%%
+%% Displays the query execution plan in a readable tree format.
+%%
+%% @param Plan Query plan tuple
+%% @returns ok
+-spec explain(query_planner:query_plan()) -> ok.
+explain(Plan) ->
+ Explanation = query_planner:explain(Plan),
+ io:format("~nQuery Plan:~n~s~n", [Explanation]),
+ ok.
+
+%% @doc List available visualization modes.
+%%
+%% @returns List of available render modes
+-spec modes() -> [visualizer:render_mode()].
+modes() ->
+ visualizer:modes().
+
+%% @doc Pretty-print a list of tuples.
+%%
+%% Displays tuples using the visualizer module (default table mode).
+%%
+%% @param Results List of result tuples
+%% @returns ok
+-spec pretty([map()]) -> ok.
+pretty(Results) ->
+ visualizer:render(Results, table).
+
+%% @doc Display help message with query examples.
+%%
+%% @returns ok
+-spec help() -> ok.
+help() ->
+ io:format("~n~s~n~n", [banner()]),
+ io:format("Domino Query REPL - Lisp-inspired tuple-based query language~n~n"),
+ io:format("Quick Start:~n"),
+ io:format(" repl:start(). %% Initialize system~n"),
+ io:format(" DB = repl:example_db(). %% Create example database~n"),
+ io:format(" repl:q(DB, {scan, employees}). %% Run a query~n~n"),
+
+ io:format("Query Operators (all lazy by default):~n"),
+ io:format(" {scan, Name} - Scan relation~n"),
+ io:format(" {select, Plan, Predicate} - Filter tuples~n"),
+ io:format(" {project, Plan, [Attrs]} - Project attributes~n"),
+ io:format(" {join, Left, Right, Attr} - Equijoin~n"),
+ io:format(" {theta_join, Left, Right, Predicate} - Theta join~n"),
+ io:format(" {sort, Plan, Comparator} - Sort tuples~n"),
+ io:format(" {take, Plan, N} - Limit results~n"),
+ io:format(" {rename, OldAttr, NewAttr, Plan} - Rename attribute~n"),
+ io:format(" {materialize, Plan} - Force evaluation (all)~n"),
+ io:format(" {materialize, Plan, N} - Force evaluation (N tuples)~n~n"),
+
+ io:format("Lazy vs Eager:~n"),
+ io:format(" All operators return lazy iterators (relations)~n"),
+ io:format(" Use 'materialize' to force eager evaluation~n"),
+ io:format(" Materialize can be nested anywhere in the query~n~n"),
+
+ io:format("Query Execution:~n"),
+ io:format(" repl:q(DB, Plan) - Execute and display (returns ok)~n"),
+ io:format(" repl:q(DB, Plan, Mode) - Execute and display with mode (returns ok)~n"),
+ io:format(" repl:query(DB, Plan) - Execute without display (returns results)~n"),
+ io:format(" repl:show(Results) - Display results (returns ok)~n"),
+ io:format(" repl:show(Results, Mode) - Display results with mode (returns ok)~n~n"),
+
+ io:format("Visualization Modes:~n"),
+ io:format(" Modes: table, tree, nested_table, linked_tables, outline~n"),
+ io:format(" repl:modes() - List all available modes~n~n"),
+
+ io:format("Examples:~n"),
+ io:format(" %% Filter employees over 30~n"),
+ io:format(" repl:q(DB, {select, {scan, employees}, ~n"),
+ io:format(" fun(T) -> maps:get(age, T) > 30 end}).~n~n"),
+
+ io:format(" %% Project name and age~n"),
+ io:format(" repl:q(DB, {project, {scan, employees}, [name, age]}).~n~n"),
+
+ io:format(" %% Join employees with departments~n"),
+ io:format(" repl:q(DB, {join, {scan, employees}, ~n"),
+ io:format(" {scan, departments}, dept_id}).~n~n"),
+
+ io:format(" %% Rename age to years~n"),
+ io:format(" repl:q(DB, {rename, age, years, {scan, employees}}).~n~n"),
+
+ io:format(" %% Materialize first 10 tuples (eager evaluation)~n"),
+ io:format(" repl:q(DB, {materialize, {scan, employees}, 10}).~n~n"),
+
+ io:format(" %% Nested materialize - select from materialized data~n"),
+ io:format(" repl:q(DB, {select, {materialize, {scan, employees}},~n"),
+ io:format(" fun(T) -> maps:get(age, T) > 30 end}).~n~n"),
+
+ io:format(" %% Use different visualization modes~n"),
+ io:format(" repl:q(DB, {scan, employees}, tree).~n"),
+ io:format(" repl:q(DB, {scan, employees}, nested_table).~n"),
+ io:format(" repl:q(DB, {scan, employees}, outline).~n~n"),
+
+ io:format(" %% Query without printing, then show results~n"),
+ io:format(" Results = repl:query(DB, {scan, employees}).~n"),
+ io:format(" Filtered = lists:filter(fun(T) -> maps:get(age, T) > 30 end, Results).~n"),
+ io:format(" repl:show(Filtered, tree).~n~n"),
+
+ io:format(" %% Explain a query plan~n"),
+ io:format(" repl:explain({select, {scan, employees}, ~n"),
+ io:format(" fun(T) -> maps:get(age, T) > 30 end}).~n~n"),
+
+ ok.
+
+%%% Internal Functions
+
+banner() ->
+ "========================================\n"
+ " Domino - Extended Relational DB\n"
+ " Tasmania / RM/T Implementation\n"
+ "========================================".
diff --git a/src/visualizer.erl b/src/visualizer.erl
new file mode 100644
index 0000000..83080f1
--- /dev/null
+++ b/src/visualizer.erl
@@ -0,0 +1,349 @@
+%%% @doc Relation Visualizer - Multiple Rendering Modes
+%%%
+%%% Provides various visual representations for nested relational data.
+%%% Supports different modes optimized for different use cases.
+%%%
+%%% == Modes ==
+%%%
+%%% - `table` - Classic flat table (default)
+%%% - `nested_table` - Tables within table cells
+%%% - `tree` - Hierarchical tree view with indentation
+%%% - `linked_tables` - Separate linked tables for nested relations
+%%% - `outline` - Indented outline format
+%%%
+%%% == Usage ==
+%%%
+%%% ```
+%%% Results = query_planner:collect(DB, Plan),
+%%% visualizer:render(Results, tree).
+%%% visualizer:render(Results, nested_table).
+%%% ```
+%%%
+%%% @author Nekoma Team
+%%% @copyright 2025
+
+-module(visualizer).
+
+-export([render/1,
+ render/2,
+ modes/0]).
+
+-type render_mode() :: table
+ | nested_table
+ | tree
+ | linked_tables
+ | outline.
+
+-export_type([render_mode/0]).
+
+%%% Public API
+
+%% @doc Render tuples with default mode (table).
+-spec render([map()]) -> ok.
+render(Tuples) ->
+ render(Tuples, table).
+
+%% @doc Render tuples with specified visualization mode.
+-spec render([map()], render_mode()) -> ok.
+render([], _Mode) ->
+ io:format("(no results)~n"),
+ ok;
+render(Tuples, table) ->
+ render_table(Tuples);
+render(Tuples, nested_table) ->
+ render_nested_table(Tuples);
+render(Tuples, tree) ->
+ render_tree(Tuples);
+render(Tuples, linked_tables) ->
+ render_linked_tables(Tuples);
+render(Tuples, outline) ->
+ render_outline(Tuples).
+
+%% @doc List available rendering modes.
+-spec modes() -> [render_mode()].
+modes() ->
+ [table, nested_table, tree, linked_tables, outline].
+
+%%% Rendering Implementations
+
+%% Mode 1: Table with proper column width calculation
+render_table(Tuples) ->
+ AllKeys = get_all_keys(Tuples),
+
+ % Calculate column widths based on content
+ Widths = calculate_column_widths(Tuples, AllKeys),
+
+ io:format("~n"),
+ print_separator_with_widths(Widths),
+ print_row_with_widths([atom_to_list(K) || K <- AllKeys], Widths),
+ print_separator_with_widths(Widths),
+
+ lists:foreach(
+ fun(Tuple) ->
+ Values = [format_value_for_table(maps:get(K, Tuple, undefined)) || K <- AllKeys],
+ print_row_with_widths(Values, Widths)
+ end,
+ Tuples
+ ),
+ print_separator_with_widths(Widths),
+ io:format("~n~b tuple(s)~n~n", [length(Tuples)]),
+ ok.
+
+calculate_column_widths(Tuples, Keys) ->
+ lists:map(
+ fun(Key) ->
+ % Start with header width
+ HeaderWidth = length(atom_to_list(Key)),
+
+ % Find max value width in this column
+ MaxValueWidth = lists:foldl(
+ fun(Tuple, MaxW) ->
+ Value = maps:get(Key, Tuple, undefined),
+ ValueStr = format_value_for_table(Value),
+ max(MaxW, length(ValueStr))
+ end,
+ HeaderWidth,
+ Tuples
+ ),
+
+ max(12, MaxValueWidth) % Minimum width of 12
+ end,
+ Keys
+ ).
+
+format_value_for_table(V) when is_map(V) ->
+ % Format nested map as inline representation
+ Keys = [K || K <- maps:keys(V), K =/= meta],
+ Parts = [atom_to_list(K) ++ ":" ++ format_value(maps:get(K, V)) || K <- Keys],
+ "[" ++ string:join(Parts, ", ") ++ "]";
+format_value_for_table(V) ->
+ format_value(V).
+
+print_separator_with_widths(Widths) ->
+ io:format("+"),
+ lists:foreach(
+ fun(W) ->
+ io:format("~s+", [lists:duplicate(W + 2, $-)])
+ end,
+ Widths
+ ),
+ io:format("~n").
+
+print_row_with_widths(Values, Widths) ->
+ io:format("| "),
+ lists:foreach(
+ fun({Val, Width}) ->
+ io:format("~*s | ", [-Width, Val])
+ end,
+ lists:zip(Values, Widths)
+ ),
+ io:format("~n").
+
+%% Mode 2: Nested tables (tables within cells)
+render_nested_table(Tuples) ->
+ io:format("~n=== Nested Table View ===~n~n"),
+ lists:foreach(
+ fun(Tuple) ->
+ render_tuple_nested(Tuple, 0)
+ end,
+ Tuples
+ ),
+ io:format("~n~b tuple(s)~n~n", [length(Tuples)]),
+ ok.
+
+render_tuple_nested(Tuple, Indent) ->
+ Keys = [K || K <- maps:keys(Tuple), K =/= meta],
+ IndentStr = lists:duplicate(Indent, $\s),
+
+ io:format("~s+-- Tuple~n", [IndentStr]),
+ lists:foreach(
+ fun(Key) ->
+ Value = maps:get(Key, Tuple),
+ case is_map(Value) andalso (Key =/= meta) of
+ true ->
+ % Nested tuple - render as inner table
+ io:format("~s| ~s:~n", [IndentStr, atom_to_list(Key)]),
+ io:format("~s| +-------------------------+~n", [IndentStr]),
+ NestedKeys = maps:keys(Value),
+ lists:foreach(
+ fun(NK) ->
+ NV = format_value(maps:get(NK, Value)),
+ io:format("~s| | ~-10s : ~-10s |~n",
+ [IndentStr, atom_to_list(NK), NV])
+ end,
+ NestedKeys
+ ),
+ io:format("~s| +-------------------------+~n", [IndentStr]);
+ false ->
+ % Simple value
+ io:format("~s| ~-15s : ~s~n",
+ [IndentStr, atom_to_list(Key), format_value(Value)])
+ end
+ end,
+ Keys
+ ),
+ io:format("~s+--~n", [IndentStr]).
+
+%% Mode 3: Tree view with hierarchy
+render_tree(Tuples) ->
+ io:format("~n=== Tree View ===~n~n"),
+ io:format("+-- Relation (~b tuples)~n", [length(Tuples)]),
+ render_tuples_as_tree(Tuples, "| ", length(Tuples)),
+ io:format("~n"),
+ ok.
+
+render_tuples_as_tree([], _Prefix, _Total) ->
+ ok;
+render_tuples_as_tree([Tuple], Prefix, Total) ->
+ io:format("~s~n~s+-- Row ~b~n", [Prefix, Prefix, Total]),
+ render_tuple_as_tree(Tuple, Prefix ++ " ");
+render_tuples_as_tree([Tuple | Rest], Prefix, Total) ->
+ Index = Total - length(Rest),
+ io:format("~s~n~s+-- Row ~b~n", [Prefix, Prefix, Index]),
+ render_tuple_as_tree(Tuple, Prefix ++ "| "),
+ render_tuples_as_tree(Rest, Prefix, Total).
+
+render_tuple_as_tree(Tuple, Prefix) ->
+ Keys = [K || K <- maps:keys(Tuple), K =/= meta],
+ render_attrs_as_tree(Keys, Tuple, Prefix).
+
+render_attrs_as_tree([], _Tuple, _Prefix) ->
+ ok;
+render_attrs_as_tree([Key], Tuple, Prefix) ->
+ Value = maps:get(Key, Tuple),
+ case is_map(Value) andalso (Key =/= meta) of
+ true ->
+ io:format("~s+-- ~s:~n", [Prefix, atom_to_list(Key)]),
+ render_tuple_as_tree(Value, Prefix ++ " ");
+ false ->
+ io:format("~s+-- ~s: ~s~n",
+ [Prefix, atom_to_list(Key), format_value(Value)])
+ end;
+render_attrs_as_tree([Key | Rest], Tuple, Prefix) ->
+ Value = maps:get(Key, Tuple),
+ case is_map(Value) andalso (Key =/= meta) of
+ true ->
+ io:format("~s+-- ~s:~n", [Prefix, atom_to_list(Key)]),
+ render_tuple_as_tree(Value, Prefix ++ "| "),
+ render_attrs_as_tree(Rest, Tuple, Prefix);
+ false ->
+ io:format("~s+-- ~s: ~s~n",
+ [Prefix, atom_to_list(Key), format_value(Value)]),
+ render_attrs_as_tree(Rest, Tuple, Prefix)
+ end.
+
+%% Mode 4: Linked tables (parent + separate child tables)
+render_linked_tables(Tuples) ->
+ io:format("~n=== Linked Tables View ===~n~n"),
+
+ % Collect nested relations with parent references
+ {FlatTuples, NestedRels} = extract_nested_relations_with_refs(Tuples),
+
+ % Render main table
+ io:format("Main Relation:~n"),
+ render_table(FlatTuples),
+
+ % Render nested tables with parent references
+ case NestedRels of
+ [] -> ok;
+ _ ->
+ io:format("~nNested Relations:~n"),
+ lists:foreach(
+ fun({RowIdx, AttrName, NestedTuple}) ->
+ io:format("~n[~s for Row ~b]:~n", [atom_to_list(AttrName), RowIdx]),
+ render_table([NestedTuple])
+ end,
+ NestedRels
+ )
+ end,
+ ok.
+
+extract_nested_relations_with_refs(Tuples) ->
+ % Process tuples with index tracking
+ {FlatTuples, NestedRels} = lists:foldl(
+ fun(Tuple, {FlatAcc, NestedAcc}) ->
+ RowIdx = length(FlatAcc) + 1,
+
+ % Flatten this tuple
+ FlatTuple = maps:map(
+ fun(K, V) when is_map(V), K =/= meta ->
+ lists:flatten(io_lib:format("[~b]", [RowIdx]));
+ (_K, V) -> V
+ end,
+ Tuple
+ ),
+
+ % Extract nested relations with parent reference
+ Nested = lists:filtermap(
+ fun(K) ->
+ case maps:get(K, Tuple) of
+ V when is_map(V), K =/= meta ->
+ {true, {RowIdx, K, V}};
+ _ ->
+ false
+ end
+ end,
+ maps:keys(Tuple)
+ ),
+
+ {[FlatTuple | FlatAcc], Nested ++ NestedAcc}
+ end,
+ {[], []},
+ Tuples
+ ),
+
+ {lists:reverse(FlatTuples), lists:reverse(NestedRels)}.
+
+%% Mode 5: Outline format (indented text)
+render_outline(Tuples) ->
+ io:format("~n=== Outline View ===~n~n"),
+ lists:foreach(
+ fun(Tuple) ->
+ render_tuple_outline(Tuple, 0)
+ end,
+ Tuples
+ ),
+ io:format("~n~b tuple(s)~n~n", [length(Tuples)]),
+ ok.
+
+render_tuple_outline(Tuple, Indent) ->
+ Keys = [K || K <- maps:keys(Tuple), K =/= meta],
+ IndentStr = lists:duplicate(Indent, $\s),
+
+ io:format("~s+-- Tuple~n", [IndentStr]),
+ lists:foreach(
+ fun(Key) ->
+ Value = maps:get(Key, Tuple),
+ case is_map(Value) andalso (Key =/= meta) of
+ true ->
+ io:format("~s| ~s:~n", [IndentStr, atom_to_list(Key)]),
+ render_tuple_outline(Value, Indent + 4);
+ false ->
+ io:format("~s| ~s: ~s~n",
+ [IndentStr, atom_to_list(Key), format_value(Value)])
+ end
+ end,
+ Keys
+ ),
+ io:format("~s+--~n", [IndentStr]).
+
+%%% Helper Functions
+
+get_all_keys(Tuples) ->
+ lists:usort(
+ lists:flatmap(
+ fun(Tuple) ->
+ [K || K <- maps:keys(Tuple), K =/= meta]
+ end,
+ Tuples
+ )
+ ).
+
+format_value(undefined) -> "(null)";
+format_value(V) when is_integer(V) -> integer_to_list(V);
+format_value(V) when is_float(V) -> float_to_list(V, [{decimals, 2}]);
+format_value(V) when is_atom(V) -> atom_to_list(V);
+format_value(V) when is_binary(V) -> binary_to_list(V);
+format_value(V) when is_list(V) -> V;
+format_value(V) when is_map(V) -> "(nested)";
+format_value(_) -> "(complex)".