-module(provenance_test).
-include_lib("eunit/include/eunit.hrl").
-include("operations.hrl").
%%% Provenance & Lineage Tracking Test Suite
%%%
%%% Tests for tuple metadata tracking in relational operations.
%%% Metadata is ALWAYS tracked and included in every tuple via the nested 'meta' field:
%%% - Provenance: Tracks the immediate source of each attribute
%%% - Lineage: Tracks the complete operational history of tuple creation
%%%
%%% NOTE ON TUPLE ORDERING:
%%% These tests observe tuples in a specific order (e.g., Alice before Bob) because
%%% collect_all/1 currently returns results in Merkle tree traversal order for finite
%%% relations. This ordering is an IMPLEMENTATION DETAIL and is NOT guaranteed.
%%% Relations are unordered sets - the current order is based on how tuples were
%%% inserted and hashed into the Merkle tree. Future implementations may return tuples
%%% in different orders. Tests use lists:all/2 to verify properties hold for ALL tuples
%%% rather than relying on specific ordering.
provenance_test_() ->
{foreach,
fun setup/0,
fun cleanup/1,
[
fun test_base_provenance/1,
fun test_join_provenance/1,
fun test_join_with_conflicts_provenance/1,
fun test_equijoin_preserves_both_sources/1,
fun test_base_lineage/1,
fun test_join_lineage/1,
fun test_select_lineage/1,
fun test_project_lineage/1,
fun test_sort_lineage/1,
fun test_take_lineage/1,
fun test_multi_operation_pipeline_lineage/1,
fun test_theta_join_lineage/1,
fun test_complex_nested_lineage/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
}),
{DB2, _} = operations:create_tuple(DB1, employees, #{id => 1, name => "Alice", dept_id => 10}),
{DB3, _} = operations:create_tuple(DB2, employees, #{id => 2, name => "Bob", dept_id => 20}),
% Create departments relation
{DB4, _} = operations:create_relation(DB3, departments, #{
dept_id => integer,
dept_name => string,
budget => integer
}),
{DB5, _} = operations:create_tuple(DB4, departments, #{dept_id => 10, dept_name => "Engineering", budget => 100000}),
{DB6, _} = operations:create_tuple(DB5, departments, #{dept_id => 20, dept_name => "Sales", budget => 80000}),
DB6.
cleanup(_DB) ->
ok.
%%% Base Provenance Tests
test_base_provenance(DB) ->
% Query tuples (metadata always included)
Iter = operations:get_tuples_iterator(DB, employees, #{}),
Results = operations:collect_all(Iter),
% All tuples should have correct provenance structure
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
maps:is_key(meta, T) andalso
begin
Meta = maps:get(meta, T),
maps:is_key(provenance, Meta)
end
end, Results)),
% Check ALL tuples have correct provenance mapping
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Prov = maps:get(provenance, Meta),
% All attributes should map to {employees, attr_name}
maps:get(id, Prov) =:= {employees, id} andalso
maps:get(name, Prov) =:= {employees, name} andalso
maps:get(dept_id, Prov) =:= {employees, dept_id}
end, Results))
].
%%% Join Provenance Tests
test_join_provenance(DB) ->
% Create iterators (metadata always included)
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
% Join them
JoinIter = operations:equijoin_iterator(EmpIter, DeptIter, dept_id),
Results = operations:collect_all(JoinIter),
% All results should have metadata with provenance
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) -> maps:is_key(meta, T) end, Results)),
% Check that ALL tuples have correct provenance structure
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Prov = maps:get(provenance, Meta),
% Employee attributes should come from employees
maps:get(id, Prov) =:= {employees, id} andalso
maps:get(name, Prov) =:= {employees, name} andalso
% Department attributes should come from departments
maps:get(dept_name, Prov) =:= {departments, dept_name} andalso
maps:get(budget, Prov) =:= {departments, budget}
end, Results))
].
test_join_with_conflicts_provenance(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 % Conflict!
}),
{DB4, _} = operations:create_tuple(DB3, table_b, #{id => 1, value => 200}),
% Create iterators with provenance
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 be exactly one result tuple
?_assertEqual(1, length(Results)),
% Check provenance handles conflicts correctly
[Result] = Results,
Meta = maps:get(meta, Result),
Prov = maps:get(provenance, Meta),
[
% Left 'value' provenance
?_assertEqual({table_a, value}, maps:get(value, Prov)),
% Right 'value' provenance (prefixed)
?_assertEqual({table_b, value}, maps:get(right_value, Prov)),
% Join attribute from left side
?_assertEqual({table_a, id}, maps:get(id, Prov)),
% Join attribute from right side (prefixed)
?_assertEqual({table_b, id}, maps:get(right_id, Prov))
].
test_equijoin_preserves_both_sources(DB) ->
% Even though equijoin only keeps one dept_id value,
% provenance should track both sources
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),
% Check that ALL tuples have both dept_id sources in provenance
[
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Prov = maps:get(provenance, Meta),
% Left side dept_id
maps:is_key(dept_id, Prov) andalso
% Right side dept_id (prefixed)
maps:is_key(right_dept_id, Prov)
end, Results)),
% Verify the sources are correct for ALL tuples
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Prov = maps:get(provenance, Meta),
maps:get(dept_id, Prov) =:= {employees, dept_id} andalso
maps:get(right_dept_id, Prov) =:= {departments, dept_id}
end, Results))
].
%%% Lineage Tests
test_base_lineage(DB) ->
% Query tuples (metadata always included)
Iter = operations:get_tuples_iterator(DB, employees, #{}),
Results = operations:collect_all(Iter),
% All tuples should have lineage showing base relation
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
Lineage =:= {base, employees}
end, Results))
].
test_join_lineage(DB) ->
% Create iterators (metadata always included)
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
% Join them
JoinIter = operations:equijoin_iterator(EmpIter, DeptIter, dept_id),
Results = operations:collect_all(JoinIter),
% All results should have lineage showing join operation
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
% Should be a join of two base relations
case Lineage of
{join, dept_id, {base, employees}, {base, departments}} -> true;
_ -> false
end
end, Results))
].
%%% Comprehensive Lineage Tests
test_select_lineage(DB) ->
% Test lineage through select (filter) operation
Iter = operations:get_tuples_iterator(DB, employees, #{}),
Predicate = fun(T) -> maps:get(id, T, 0) =:= 1 end,
FilteredIter = operations:select_iterator(Iter, Predicate),
Results = operations:collect_all(FilteredIter),
% Should have lineage: select wrapping base
[
?_assertEqual(1, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{select, Pred, {base, employees}} when is_function(Pred) -> true;
_ -> false
end
end, Results))
].
test_project_lineage(DB) ->
% Test lineage through project operation
Iter = operations:get_tuples_iterator(DB, employees, #{}),
ProjectedIter = operations:project_iterator(Iter, [name, dept_id]),
Results = operations:collect_all(ProjectedIter),
% Should have lineage: project wrapping base
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{project, [name, dept_id], {base, employees}} -> true;
_ -> false
end
end, Results))
].
test_sort_lineage(DB) ->
% Test lineage through sort operation
Iter = operations:get_tuples_iterator(DB, employees, #{}),
CompareFun = fun(A, B) -> maps:get(id, A, 0) =< maps:get(id, B, 0) end,
SortedIter = operations:sort_iterator(Iter, CompareFun),
Results = operations:collect_all(SortedIter),
% Should have lineage: sort wrapping base
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{sort, Cmp, {base, employees}} when is_function(Cmp) -> true;
_ -> false
end
end, Results))
].
test_take_lineage(DB) ->
% Test lineage through take (limit) operation
Iter = operations:get_tuples_iterator(DB, employees, #{}),
TakeIter = operations:take_iterator(Iter, 1),
Results = operations:collect_all(TakeIter),
% Should have lineage: take wrapping base
[
?_assertEqual(1, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{take, 1, {base, employees}} -> true;
_ -> false
end
end, Results))
].
test_multi_operation_pipeline_lineage(DB) ->
% Test complex pipeline: employees → select(id=1) → join(departments) → project([name, dept_name])
% 1. Start with employees base relation
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
% 2. Filter to Alice (id=1)
Predicate = fun(T) -> maps:get(id, T, 0) =:= 1 end,
FilteredEmp = operations:select_iterator(EmpIter, Predicate),
% 3. Join with departments
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
JoinedIter = operations:equijoin_iterator(FilteredEmp, DeptIter, dept_id),
% 4. Project to [name, dept_name]
ProjectedIter = operations:project_iterator(JoinedIter, [name, dept_name]),
Results = operations:collect_all(ProjectedIter),
% Expected lineage:
% {project, [name, dept_name],
% {join, dept_id,
% {select, Pred, {base, employees}},
% {base, departments}}}
[
?_assertEqual(1, length(Results)), % Only Alice
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{project, [name, dept_name],
{join, dept_id,
{select, Pred, {base, employees}},
{base, departments}}} when is_function(Pred) -> true;
_ -> false
end
end, Results))
].
test_theta_join_lineage(DB) ->
% Test lineage for theta-join operation
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
Predicate = fun(Emp, Dept) ->
maps:get(dept_id, Emp) =:= maps:get(dept_id, Dept)
end,
JoinIter = operations:theta_join_iterator(EmpIter, DeptIter, Predicate),
Results = operations:collect_all(JoinIter),
% Should have theta_join lineage
[
?_assertEqual(2, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{theta_join, Pred, {base, employees}, {base, departments}}
when is_function(Pred) -> true;
_ -> false
end
end, Results))
].
test_complex_nested_lineage(DB) ->
% Test deeply nested lineage:
% employees → select(id=1) → project([name, dept_id]) → join(departments → select(budget>50000)) → take(1)
% Left side: employees → select → project
EmpIter = operations:get_tuples_iterator(DB, employees, #{}),
FilteredEmp = operations:select_iterator(EmpIter, fun(T) -> maps:get(id, T, 0) =:= 1 end),
ProjectedEmp = operations:project_iterator(FilteredEmp, [name, dept_id]),
% Right side: departments → select
DeptIter = operations:get_tuples_iterator(DB, departments, #{}),
FilteredDept = operations:select_iterator(DeptIter, fun(D) -> maps:get(budget, D, 0) > 50000 end),
% Join both sides
JoinedIter = operations:equijoin_iterator(ProjectedEmp, FilteredDept, dept_id),
% Take first result
TakeIter = operations:take_iterator(JoinedIter, 1),
Results = operations:collect_all(TakeIter),
% Expected lineage should be deeply nested
[
?_assertEqual(1, length(Results)),
?_assert(lists:all(fun(T) ->
Meta = maps:get(meta, T),
Lineage = maps:get(lineage, Meta),
case Lineage of
{take, 1,
{join, dept_id,
{project, [name, dept_id],
{select, _, {base, employees}}},
{select, _, {base, departments}}}} -> true;
_ -> false
end
end, Results))
].