-module(join_test).
-include_lib("eunit/include/eunit.hrl").
-include("operations.hrl").
%%% Join Operators Test Suite
%%%
%%% Tests for relational join operations:
%%% - Equijoin (natural join on attribute equality)
%%% - Theta-join (join with arbitrary predicate)
join_test_() ->
{foreach,
fun setup/0,
fun cleanup/1,
[
fun test_equijoin_simple/1,
fun test_equijoin_multiple_matches/1,
fun test_equijoin_no_matches/1,
fun test_equijoin_with_conflicts/1,
fun test_theta_join_greater_than/1,
fun test_theta_join_complex_predicate/1,
fun test_join_with_infinite_relation/1,
fun test_join_materialize_pipeline/1
]}.
%%% Setup and Cleanup
setup() ->
operations:setup(),
DB = operations:create_database(test_db),
% Create employees relation
{DB1, _} = operations:create_relation(DB, employees, #{
id => integer,
name => string,
dept_id => integer,
age => integer
}),
{DB2, _} = operations:create_tuple(DB1, employees, #{id => 1, name => "Alice", dept_id => 10, age => 30}),
{DB3, _} = operations:create_tuple(DB2, employees, #{id => 2, name => "Bob", dept_id => 20, age => 35}),
{DB4, _} = operations:create_tuple(DB3, employees, #{id => 3, name => "Carol", dept_id => 10, age => 28}),
{DB5, _} = operations:create_tuple(DB4, employees, #{id => 4, name => "Dave", dept_id => 30, age => 42}),
% Create departments relation
{DB6, _} = operations:create_relation(DB5, departments, #{
dept_id => integer,
dept_name => string,
budget => integer
}),
{DB7, _} = operations:create_tuple(DB6, departments, #{dept_id => 10, dept_name => "Engineering", budget => 100000}),
{DB8, _} = operations:create_tuple(DB7, departments, #{dept_id => 20, dept_name => "Sales", budget => 80000}),
{DB9, _} = operations:create_tuple(DB8, departments, #{dept_id => 30, dept_name => "Marketing", budget => 90000}),
DB9.
cleanup(_DB) ->
ok.
%%% Equijoin Tests
test_equijoin_simple(DB) ->
% Join employees with departments on dept_id
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
JoinIter = operations:equijoin_iterator(EmpIter, DeptIter, dept_id),
Results = operations:collect_all(JoinIter),
% Should get 4 joined tuples (one for each employee)
[
?_assertEqual(4, length(Results)),
% Check Alice joined with Engineering
?_assert(lists:any(fun(T) ->
maps:get(name, T, undefined) =:= "Alice" andalso
maps:get(dept_name, T, undefined) =:= "Engineering"
end, Results)),
% Check Bob joined with Sales
?_assert(lists:any(fun(T) ->
maps:get(name, T, undefined) =:= "Bob" andalso
maps:get(dept_name, T, undefined) =:= "Sales"
end, Results)),
% All results should have both employee and department attributes
?_assert(lists:all(fun(T) ->
maps:is_key(name, T) andalso
maps:is_key(dept_name, T) andalso
maps:is_key(dept_id, T)
end, Results))
].
test_equijoin_multiple_matches(DB) ->
% Multiple employees in same department should produce multiple results
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
JoinIter = operations:equijoin_iterator(EmpIter, DeptIter, dept_id),
Results = operations:collect_all(JoinIter),
% Engineering dept (10) has 2 employees (Alice and Carol)
EngineeringResults = lists:filter(fun(T) ->
maps:get(dept_name, T, undefined) =:= "Engineering"
end, Results),
[
?_assertEqual(2, length(EngineeringResults)),
?_assert(lists:any(fun(T) -> maps:get(name, T) =:= "Alice" end, EngineeringResults)),
?_assert(lists:any(fun(T) -> maps:get(name, T) =:= "Carol" end, EngineeringResults))
].
test_equijoin_no_matches(DB) ->
% Create a department with no employees
{DB1, _} = operations:create_tuple(DB, departments, #{dept_id => 40, dept_name => "HR", budget => 70000}),
% Filter to only dept_id=40 which has no employees
DeptIter = operations:get_tuples_iterator(DB1, departments, #{}),
FilteredDept = operations:select_iterator(DeptIter, fun(D) -> maps:get(dept_id, D) =:= 40 end),
EmpIter = operations:get_tuples_iterator(DB1, employees, #{}),
JoinIter = operations:equijoin_iterator(EmpIter, FilteredDept, dept_id),
Results = operations:collect_all(JoinIter),
% No employees in HR, so no results
[
?_assertEqual(0, length(Results))
].
test_equijoin_with_conflicts(DB) ->
% Create relations with conflicting attribute names
{DB1, _} = operations:create_relation(DB, table_a, #{
id => integer,
value => integer
}),
{DB2, _} = operations:create_tuple(DB1, table_a, #{id => 1, value => 100}),
{DB3, _} = operations:create_relation(DB2, table_b, #{
id => integer,
value => integer % Same name as table_a.value
}),
{DB4, _} = operations:create_tuple(DB3, table_b, #{id => 1, value => 200}),
AIter = operations:get_tuples_iterator(DB4, table_a, #{}),
BIter = operations:get_tuples_iterator(DB4, table_b, #{}),
JoinIter = operations:equijoin_iterator(AIter, BIter, id),
Results = operations:collect_all(JoinIter),
% Should have one result with both values
% Right side 'value' should be prefixed as 'right_value'
[
?_assertEqual(1, length(Results)),
?_assertMatch([#{id := 1, value := 100, right_value := 200}], Results)
].
%%% Theta-Join Tests
test_theta_join_greater_than(DB) ->
% Join where employee.age > department.budget/1000
% (contrived example for testing)
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
Predicate = fun(Emp, Dept) ->
Age = maps:get(age, Emp),
Budget = maps:get(budget, Dept),
Age > (Budget div 2000) % Age > Budget/2000
end,
JoinIter = operations:theta_join_iterator(EmpIter, DeptIter, Predicate),
Results = operations:collect_all(JoinIter),
% Should get some results based on predicate
[
?_assert(length(Results) > 0),
% Verify predicate holds for all results
?_assert(lists:all(fun(T) ->
Age = maps:get(age, T),
Budget = maps:get(budget, T),
Age > (Budget div 2000)
end, Results))
].
test_theta_join_complex_predicate(DB) ->
% Join with multiple conditions
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
% Join where employee is in department AND age > 30
Predicate = fun(Emp, Dept) ->
maps:get(dept_id, Emp) =:= maps:get(dept_id, Dept) andalso
maps:get(age, Emp) > 30
end,
JoinIter = operations:theta_join_iterator(EmpIter, DeptIter, Predicate),
Results = operations:collect_all(JoinIter),
% Should get Bob (35) and Dave (42), not Alice (30) or Carol (28)
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) -> maps:get(age, T) > 30 end, Results)),
?_assert(lists:any(fun(T) -> maps:get(name, T) =:= "Bob" end, Results)),
?_assert(lists:any(fun(T) -> maps:get(name, T) =:= "Dave" end, Results))
].
%%% Integration Tests
test_join_with_infinite_relation(DB) ->
% Create integers relation
{DB1, _} = operations:create_infinite_relation(DB, #{
name => integers,
schema => #{},
cardinality => aleph_zero,
generator => {primitive, integers}
}),
% Get small set of integers
IntIter = operations:get_tuples_iterator(DB1, integers, #{value => {range, 1, 5}}),
% Get employees
EmpIter = operations:get_tuples_iterator(DB1, employees, #{}),
% Join where employee.id matches integer.value
Predicate = fun(Emp, Int) ->
maps:get(id, Emp) =:= maps:get(value, Int)
end,
JoinIter = operations:theta_join_iterator(EmpIter, IntIter, Predicate),
Results = operations:collect_all(JoinIter),
% Should get employees with id in [1,5] (all 4 employees)
[
?_assertEqual(4, length(Results)),
?_assert(lists:all(fun(T) ->
Id = maps:get(id, T),
Value = maps:get(value, T),
Id =:= Value andalso Id >= 1 andalso Id =< 5
end, Results))
].
test_join_materialize_pipeline(DB) ->
% Complex pipeline: join, filter, project, materialize
% 1. Join employees with departments
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
JoinIter = operations:equijoin_iterator(EmpIter, DeptIter, dept_id),
% 2. Filter to high-budget departments
FilteredIter = operations:select_iterator(JoinIter, fun(T) ->
maps:get(budget, T) >= 90000
end),
% 3. Project to [name, dept_name, budget]
ProjectedIter = operations:project_iterator(FilteredIter, [name, dept_name, budget]),
% 4. Materialize
{DB1, HighBudgetEmps} = operations:materialize(DB, ProjectedIter, high_budget_employees),
% Verify materialized relation
ResultIter = operations:get_tuples_iterator(DB1, high_budget_employees, #{}),
Results = operations:collect_all(ResultIter),
% Engineering (100k) has Alice and Carol
% Marketing (90k) has Dave
% Sales (80k) is excluded
[
?_assertEqual({finite, 3}, HighBudgetEmps#relation.cardinality),
?_assertEqual(3, length(Results)),
?_assert(lists:all(fun(T) ->
maps:is_key(name, T) andalso
maps:is_key(dept_name, T) andalso
maps:is_key(budget, T) andalso
not maps:is_key(id, T) andalso % Projected out
not maps:is_key(age, T) % Projected out
end, Results)),
?_assert(lists:all(fun(T) -> maps:get(budget, T) >= 90000 end, Results))
].