diff options
| author | Marcos Magueta <maguetamarcos@gmail.com> | 2025-11-25 15:59:08 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-25 15:59:08 -0300 |
| commit | 3f7cb91ef7ae44d3f641f2e77826965fcd0afeed (patch) | |
| tree | adb493947fe7c3ee511346c984f8046b4c48856e | |
| parent | 62a017ce95ad2f6d9445b5b96f7aeba3f7c0d42f (diff) | |
| download | Sakura-3f7cb91ef7ae44d3f641f2e77826965fcd0afeed.tar.gz Sakura-3f7cb91ef7ae44d3f641f2e77826965fcd0afeed.zip | |
Pseudo-REPL and sketch planner (#12)
| -rw-r--r-- | rebar.config | 12 | ||||
| -rw-r--r-- | src/operations.erl | 17 | ||||
| -rw-r--r-- | src/query_planner.erl | 359 | ||||
| -rw-r--r-- | src/repl.erl | 333 | ||||
| -rw-r--r-- | src/visualizer.erl | 349 |
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)". |
