aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarcos Magueta <maguetamarcos@gmail.com>2025-12-14 00:27:18 -0300
committerGitHub <noreply@github.com>2025-12-14 00:27:18 -0300
commitff5691ffb1a969e50e0bd5e4638be8e2bb342c4e (patch)
tree565346b82901f2ee26cb49b04e1e99ba1fb0e896
parent0862cd7c3fe4fbb5b56a0a87c3789c8361bc022c (diff)
downloadSakura-ff5691ffb1a969e50e0bd5e4638be8e2bb342c4e.tar.gz
Sakura-ff5691ffb1a969e50e0bd5e4638be8e2bb342c4e.zip
Ephemeral relations, Refactor & Cleanup (#34)
-rw-r--r--.gitignore1
-rw-r--r--include/operations.hrl24
-rw-r--r--rebar-deps.nix10
-rw-r--r--rebar.config19
-rw-r--r--rebar.lock12
-rw-r--r--src/main.erl60
-rw-r--r--src/operations.erl593
-rw-r--r--src/query_planner.erl356
-rw-r--r--src/relational_operators.erl366
-rw-r--r--src/repl.erl49
-rw-r--r--src/xml_server.erl9
-rw-r--r--src/xml_server_app.erl2
-rw-r--r--test/function_relations_test.erl347
-rw-r--r--test/infinite_relations_test.erl128
-rw-r--r--test/join_test.erl273
-rw-r--r--test/operations_test.erl366
-rw-r--r--test/provenance_test.erl420
-rw-r--r--test/query_pipeline_test.erl183
18 files changed, 924 insertions, 2294 deletions
diff --git a/.gitignore b/.gitignore
index 867b65f..2f4880d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.direnv
_build
Mnesia.*
diff --git a/include/operations.hrl b/include/operations.hrl
index b027bb0..dcaa287 100644
--- a/include/operations.hrl
+++ b/include/operations.hrl
@@ -11,15 +11,15 @@
cardinality, % {finite, N} | aleph_zero | continuum
generator, % Generator function (immutable only), undefined for tree-based
membership_criteria, % #{attribute_name => constraint_spec()} - domain membership test
- mutability, % mutable | immutable
- provenance % Provenance tracking for updatable views (future)
+ provenance, % attribute_provenance() - maps attribute to source relation
+ lineage % lineage_op() - operation tree showing derivation
}).
-record(tuple, {hash, relation, attribute_map}).
-record(attribute, {hash, value}).
%% Specification record for creating immutable relations
--record(immutable_relation_spec, {
+-record(domain, {
name, % Atom - relation name
schema, % #{attribute_name => relation_name}
generator, % {module, function} - tuple generator
@@ -33,11 +33,6 @@
| aleph_zero % Countably infinite (ℵ₀)
| continuum. % Uncountably infinite (2^ℵ₀)
-%%% Mutability Types
-
--type mutability() :: mutable % Supports insert/delete (tree-based)
- | immutable. % Read-only (generator-based or protected)
-
%%% Constraint Types
-type constraint_spec() ::
@@ -108,16 +103,3 @@
| {take, pos_integer(), lineage_op()} % Limit operation
| {aggregate, atom(), atom(), lineage_op()}. % Aggregation (future)
-%% Tuple metadata (contains provenance, lineage, and other system-managed fields)
--type tuple_metadata() :: #{
- provenance => attribute_provenance(),
- lineage => lineage_op()
- % Future: timestamp, version, etc.
-}.
-
-%% Tuples with metadata use a nested 'meta' field:
-%% #{id => 1, name => "Alice",
-%% meta => #{
-%% provenance => #{id => {employees, id}, name => {employees, name}},
-%% lineage => {select, fun(T) -> maps:get(age, T) > 30 end, {base, employees}}
-%% }}
diff --git a/rebar-deps.nix b/rebar-deps.nix
index 178f889..5e167a7 100644
--- a/rebar-deps.nix
+++ b/rebar-deps.nix
@@ -14,5 +14,15 @@ let
};
beamDeps = [ ];
};
+ uuid_erl = builder {
+ name = "uuid_erl";
+ version = "2.0.7";
+ src = fetchHex {
+ pkg = "uuid_erl";
+ version = "2.0.7";
+ sha256 = "sha256-Tkxco0YdxHxeFX7UKqOYGgU7ehhnkq+XKiexSpSJMk4=";
+ };
+ beamDeps = [ ];
+ };
};
in self
diff --git a/rebar.config b/rebar.config
index 8f364e6..b419da1 100644
--- a/rebar.config
+++ b/rebar.config
@@ -9,8 +9,9 @@
%% == Dependencies and Plugins ==
{deps, [
- {merklet, "2.0.0"}]
-}.
+ {merklet, "2.0.0"},
+ {uuid_erl, "2.0.7"}
+]}.
{plugins, [
{covertool, "2.0.7"},
@@ -23,7 +24,8 @@
%% == Releases ==
{relx, [
{release, {domino, "0.1.0"}, [
- merklet
+ merklet,
+ uuid_erl
]},
{mode, dev},
@@ -80,17 +82,10 @@
%% == Dialyzer Settings ==
{dialyzer, [
- {plt_extra_apps, [merklet]},
+ {plt_extra_apps, [merklet, uuid_erl, quickrand]},
{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
+
]}
]}.
diff --git a/rebar.lock b/rebar.lock
index 4015062..b0ee48b 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -1,8 +1,14 @@
{"1.2.0",
-[{<<"merklet">>,{pkg,<<"merklet">>,<<"2.0.0">>},0}]}.
+[{<<"merklet">>,{pkg,<<"merklet">>,<<"2.0.0">>},0},
+ {<<"quickrand">>,{pkg,<<"quickrand">>,<<"2.0.7">>},1},
+ {<<"uuid_erl">>,{pkg,<<"uuid_erl">>,<<"2.0.7">>},0}]}.
[
{pkg_hash,[
- {<<"merklet">>, <<"9E53AA5F83B4CE33A8EE0BEDC798AA54A1750A8FD92D649FC04BA7F7B8E65E78">>}]},
+ {<<"merklet">>, <<"9E53AA5F83B4CE33A8EE0BEDC798AA54A1750A8FD92D649FC04BA7F7B8E65E78">>},
+ {<<"quickrand">>, <<"D2BD76676A446E6A058D678444B7FDA1387B813710D1AF6D6E29BB92186C8820">>},
+ {<<"uuid_erl">>, <<"B2078D2CC814F53AFA52D36C91E08962C7E7373585C623F4C0EA6DFB04B2AF94">>}]},
{pkg_hash_ext,[
- {<<"merklet">>, <<"B5E868DA5979F9B3C3E4E8B8171AA5C167868E35495B81731C93D8382FDD3278">>}]}
+ {<<"merklet">>, <<"B5E868DA5979F9B3C3E4E8B8171AA5C167868E35495B81731C93D8382FDD3278">>},
+ {<<"quickrand">>, <<"B8ACBF89A224BC217C3070CA8BEBC6EB236DBE7F9767993B274084EA044D35F0">>},
+ {<<"uuid_erl">>, <<"4E4C5CA3461DC47C5E157ED42AA3981A053B7A186792AF972A27B14A9489324E">>}]}
].
diff --git a/src/main.erl b/src/main.erl
index 5b8d6e3..d2fe0a7 100644
--- a/src/main.erl
+++ b/src/main.erl
@@ -1,7 +1,54 @@
-module(main).
-behaviour(application).
--export([start/2, stop/1]).
+-export([start/2, stop/1, setup/0]).
+
+%% @doc Initialize Mnesia database schema and tables.
+%%
+%% Creates a fresh Mnesia schema with four tables for content-addressed storage:
+%% <ul>
+%% <li>`attribute' - Hash → Value mapping for deduplication</li>
+%% <li>`tuple' - Hash → Tuple mapping with attribute references</li>
+%% <li>`relation' - Hash → Relation with merkle tree and schema</li>
+%% <li>`database_state' - Hash → Database with relation merkle tree</li>
+%% </ul>
+%%
+%% <b>Warning:</b> This function deletes any existing schema and recreates it from scratch.
+%%
+%% @returns `ok' on success
+%% @see operations:create_database/1
+setup() ->
+ mnesia:stop(),
+ mnesia:delete_schema([node()]),
+ mnesia:create_schema([node()]),
+ mnesia:start(),
+ mnesia:create_table(attribute, [
+ {attributes, [hash, value]},
+ {disc_copies, [node()]},
+ {type, set}
+ ]),
+ mnesia:create_table(tuple, [
+ {attributes, [hash, relation, attribute_map]},
+ {disc_copies, [node()]},
+ {type, set}
+ ]),
+ mnesia:create_table(relation, [
+ {attributes, [hash, name, tree, schema, constraints, cardinality, generator, membership_criteria, mutability, provenance, lineage]},
+ {disc_copies, [node()]},
+ {type, set},
+ {index, [name]} % Index on name for efficient lookup
+ ]),
+ mnesia:create_table(database_state, [
+ {attributes, [hash, name, tree, relations, timestamp]},
+ {disc_copies, [node()]},
+ {type, set}
+ ]).
+
+uuid() ->
+ ok = application:load(quickrand),
+ ok = quickrand:seed(),
+ ok = quickrand_cache:init(),
+ quickrand_cache:new().
start(_StartType, _StartArgs) ->
%% Start Mnesia (or other children) here
@@ -9,18 +56,11 @@ start(_StartType, _StartArgs) ->
%% Ensure Mnesia is started
mnesia:start(),
+ setup(),
+ _ = uuid(),
{ok, self()}.
stop(_State) ->
io:format("Stopping app...~n"),
ok.
-
-%% setup() ->
-%% mnesia:create_table(attributes, [{type, set},
-%% {attributes, [name, value, type]},
-%% {ram_copies, [node()]},
-%% {index, [name]}
-%% ]).
-
-
diff --git a/src/operations.erl b/src/operations.erl
index 4a492f4..e224800 100644
--- a/src/operations.erl
+++ b/src/operations.erl
@@ -38,25 +38,24 @@
%%% @reference E. F. Codd (1970). "A Relational Model of Data for Large Shared Data Banks"
-module(operations).
--include("operations.hrl").
--export([setup/0,
- hash/1,
- create_database/1,
+-include("../include/operations.hrl").
+-export([
+ create_database/1,
create_relation/3,
create_immutable_relation/2,
create_tuple/3,
+ create_all_tuples/3,
retract_tuple/3,
retract_tuple/4,
clear_relation/2,
clear_relation/3,
retract_relation/2,
retract_relation/3,
- get_relations/1,
- get_relation_hash/2,
- hashes_from_tuple/1,
- get_tuples_iterator/2,
- get_tuples_iterator/3,
- take/3,
+ get_relation_hash/2,
+ get_relations/1,
+ hashes_from_tuple/1,
+ get_tuples_iterator/2,
+ get_iterator_from_generator/2,
next_tuple/1,
close_iterator/1,
collect_all/1,
@@ -69,48 +68,10 @@
equijoin_iterator/3,
theta_join_iterator/3,
%% Materialization (eager)
- materialize/3]).
-
-%% @doc Initialize Mnesia database schema and tables.
-%%
-%% Creates a fresh Mnesia schema with four tables for content-addressed storage:
-%% <ul>
-%% <li>`attribute' - Hash → Value mapping for deduplication</li>
-%% <li>`tuple' - Hash → Tuple mapping with attribute references</li>
-%% <li>`relation' - Hash → Relation with merkle tree and schema</li>
-%% <li>`database_state' - Hash → Database with relation merkle tree</li>
-%% </ul>
-%%
-%% <b>Warning:</b> This function deletes any existing schema and recreates it from scratch.
-%%
-%% @returns `ok' on success
-%% @see create_database/1
-setup() ->
- mnesia:stop(),
- mnesia:delete_schema([node()]),
- mnesia:create_schema([node()]),
- mnesia:start(),
- mnesia:create_table(attribute, [
- {attributes, [hash, value]},
- {disc_copies, [node()]},
- {type, set}
- ]),
- mnesia:create_table(tuple, [
- {attributes, [hash, relation, attribute_map]},
- {disc_copies, [node()]},
- {type, set}
- ]),
- mnesia:create_table(relation, [
- {attributes, [hash, name, tree, schema, constraints, cardinality, generator, membership_criteria, mutability, provenance]},
- {disc_copies, [node()]},
- {type, set}
- ]),
- mnesia:create_table(database_state, [
- {attributes, [hash, name, tree, relations, timestamp]},
- {disc_copies, [node()]},
- {type, set}
- ]).
+ materialize/3
+]).
+%% @private
%% @doc Compute SHA-256 hash of an Erlang term.
%%
%% This is the foundational hashing function used throughout the system for
@@ -157,18 +118,10 @@ create_tuple(Database, RelationName, Tuple) when is_map(Tuple), is_record(Databa
%% Check if relation is immutable before attempting insert
CurrentRelationHash = maps:get(RelationName, Database#database_state.relations),
[RelationRecord] = mnesia:dirty_read(relation, CurrentRelationHash),
+ create_tuple_internal(Database, RelationName, Tuple, RelationRecord).
- case RelationRecord#relation.mutability of
- immutable ->
- {error, {immutable_relation, RelationName}};
- mutable ->
- create_tuple_internal(Database, RelationName, Tuple, RelationRecord);
- undefined ->
- %% Backwards compatibility: treat undefined as mutable
- create_tuple_internal(Database, RelationName, Tuple, RelationRecord)
- end.
-
-%% Internal function for tuple creation (after mutability check)
+%% @private
+%% Internal function for tuple creation
create_tuple_internal(Database, RelationName, Tuple, _RelationRecord) ->
F = fun() ->
%% Step 1: Store each attribute value and build attribute_map
@@ -212,8 +165,13 @@ create_tuple_internal(Database, RelationName, Tuple, _RelationRecord) ->
constraints = RelationRecord#relation.constraints,
cardinality = NewCardinality,
generator = RelationRecord#relation.generator,
- provenance = RelationRecord#relation.provenance
+ provenance = RelationRecord#relation.provenance,
+ lineage = RelationRecord#relation.lineage,
+ membership_criteria = RelationRecord#relation.membership_criteria
},
+ %% Delete old relation record before writing new one
+ %% This ensures only one relation record exists per name
+ mnesia:delete({relation, CurrentRelationHash}),
mnesia:write(relation, UpdatedRelation, write),
%% Step 7: Update database relations map
@@ -423,6 +381,20 @@ create_relation(Database, Name, Definition) when is_record(Database, database_st
%% Two relations might share definitions but their name gives another interpretation
%% and therefore different tuples
RelationHash = hash({Name, Definition, undefined}),
+
+ %% Returns a generator function (not a PID) that yields tuples one at a time
+ %% TODO: investigate if a generator should or should not be bound to a specific DATABASE VERSION. If so, create the fun passing the DB as an attribute
+ GeneratorFun = fun() ->
+ [CurrentRelation] = mnesia:dirty_index_read(relation, Name, #relation.name),
+ CurrentHash = CurrentRelation#relation.hash,
+ TupleHashes = case CurrentRelation#relation.tree of
+ undefined -> [];
+ _ -> hashes_from_tuple(CurrentHash)
+ end,
+ InnerGen = make_finite_generator(TupleHashes),
+ spawn(fun() -> generator_iterator_loop(InnerGen) end)
+ end,
+
NewRelation = #relation{
hash = RelationHash,
name = Name,
@@ -430,10 +402,10 @@ create_relation(Database, Name, Definition) when is_record(Database, database_st
schema = Definition,
constraints = #{}, % No constraints by default
cardinality = {finite, 0}, % Empty relation
- generator = undefined, % Finite relation has no generator
+ generator = GeneratorFun, % Function generator for finite relations
membership_criteria = #{}, % No membership criteria by default
- mutability = mutable, % Mutable by default
- provenance = {base, Name} % Base relation
+ provenance = build_base_provenance(Definition, Name), % Base relation provenance
+ lineage = {base, Name} % Base relation lineage
},
mnesia:write(relation, NewRelation, write),
@@ -494,42 +466,42 @@ create_database(Name) ->
%% Standard immutable relations (domains and their predicates)
StandardTypeRelations = [
%% Boolean domain - finite, immutable
- #immutable_relation_spec{
+ #domain{
name = boolean,
schema = #{value => boolean},
- generator = {generators, boolean},
+ generator = fun() -> spawn(fun() -> generator_iterator_loop(generators:boolean(#{})) end) end,
membership_criteria = #{value => {in, [true, false]}},
cardinality = {finite, 2}
},
%% Natural numbers - infinite, immutable
- #immutable_relation_spec{
+ #domain{
name = natural,
schema = #{value => natural},
- generator = {generators, natural},
+ generator = fun() -> spawn(fun() -> generator_iterator_loop(generators:natural(#{})) end) end,
membership_criteria = #{value => {'and', {gte, 0}, is_integer}},
cardinality = aleph_zero
},
%% Integers - infinite, immutable
- #immutable_relation_spec{
+ #domain{
name = integer,
schema = #{value => integer},
- generator = {generators, integer},
+ generator = fun() -> spawn(fun() -> generator_iterator_loop(generators:integer(#{})) end) end,
membership_criteria = #{value => is_integer},
cardinality = aleph_zero
},
%% Rationals - infinite, immutable
- #immutable_relation_spec{
+ #domain{
name = rational,
schema = #{numerator => integer, denominator => integer},
- generator = {generators, rational},
+ generator = fun() -> spawn(fun() -> generator_iterator_loop(generators:rational(#{})) end) end,
membership_criteria = #{denominator => {neq, 0}},
cardinality = aleph_zero
}
%% Reals - TODO: implement
- %% #immutable_relation_spec{
+ %% #domain{
%% name = reals,
%% schema = #{value => reals},
- %% generator = {generators, reals},
+ %% generator = fun() -> spawn(fun() -> generator_iterator_loop(generators:reals(#{})) end) end,
%% membership_criteria = #{value => is_float},
%% cardinality = continuum
%% }
@@ -561,7 +533,7 @@ create_database(Name) ->
%% == Example ==
%% <pre>
%% %% Create immutable relation of natural numbers
-%% {DB1, Naturals} = operations:create_immutable_relation(DB, #immutable_relation_spec{
+%% {DB1, Naturals} = operations:create_immutable_relation(DB, #domain{
%% name = naturals,
%% schema = #{value => naturals},
%% generator = {generators, naturals},
@@ -575,19 +547,19 @@ create_database(Name) ->
%% </pre>
%%
%% @param Database `#database_state{}' record
-%% @param Specification `#immutable_relation_spec{}' record
+%% @param Specification `#domain{}' record
%% @returns `{UpdatedDatabase, NewRelation}'
%%
%% @see create_relation/3
%% @see get_tuples_iterator/3
create_immutable_relation(Database, Specification)
when is_record(Database, database_state)
- andalso is_record(Specification, immutable_relation_spec) ->
- Name = Specification#immutable_relation_spec.name,
- Schema = Specification#immutable_relation_spec.schema,
- Generator = Specification#immutable_relation_spec.generator,
- MembershipCriteria = Specification#immutable_relation_spec.membership_criteria,
- Cardinality = Specification#immutable_relation_spec.cardinality,
+ andalso is_record(Specification, domain) ->
+ Name = Specification#domain.name,
+ Schema = Specification#domain.schema,
+ Generator = Specification#domain.generator,
+ MembershipCriteria = Specification#domain.membership_criteria,
+ Cardinality = Specification#domain.cardinality,
F = fun() ->
RelationHash = hash({Name, Schema, undefined}),
@@ -601,8 +573,8 @@ create_immutable_relation(Database, Specification)
cardinality = Cardinality,
generator = Generator,
membership_criteria = MembershipCriteria,
- mutability = immutable, % Always immutable
- provenance = {base, Name}
+ provenance = build_base_provenance(Schema, Name), % Base relation provenance
+ lineage = {base, Name} % Base relation lineage
},
mnesia:write(relation, NewRelation, write),
@@ -627,19 +599,6 @@ create_immutable_relation(Database, Specification)
{atomic, Result} = mnesia:transaction(F),
Result.
-%% @doc Get all relation names in a database.
-%%
-%% Returns a list of atoms representing all relation names currently
-%% defined in the database.
-%%
-%% @param Database `#database_state{}' record
-%% @returns List of atoms (relation names)
-%%
-%% @see get_relation_hash/2
-%% @see create_relation/3
-get_relations(Database) ->
- maps:keys(Database#database_state.relations).
-
%% @doc Get relation hash by name.
%%
%% Looks up a relation's hash in the database's relation map. This hash
@@ -657,6 +616,17 @@ get_relation_hash(Database, RelationName) ->
X -> X
end.
+%% @doc Get list of all relation names in the database.
+%%
+%% Returns a list of all relation names (atoms) currently stored in the database.
+%% This includes both user-defined relations and built-in infinite relations.
+%%
+%% @param Database `#database_state{}' record
+%% @returns List of relation names (atoms)
+-spec get_relations(#database_state{}) -> [atom()].
+get_relations(Database) ->
+ maps:keys(Database#database_state.relations).
+
%% @doc Resolve a tuple hash to actual values.
%%
%% This is an internal function that materializes a tuple by:
@@ -699,75 +669,6 @@ hashes_from_tuple(RelationHash) ->
Tree -> merklet:keys(Tree)
end.
-%% @doc Take operator (τ): Returns N arbitrary elements from a relation.
-%%
-%% The take operator is a unary relational operator that produces a finite
-%% relation containing N arbitrary elements from the input relation. Since
-%% relations are sets (unordered), the specific elements returned are
-%% determined by the enumeration order.
-%%
-%% For finite relations smaller than N, returns all elements.
-%% For infinite relations, returns first N elements by generator order.
-%%
-%% == Example ==
-%% <pre>
-%% %% Take 100 naturals (returns {0, 1, 2, ..., 99})
-%% {DB1, Naturals100} = operations:take(DB, naturals, 100).
-%%
-%% %% Can compose with other operators
-%% Iterator = operations:get_tuples_iterator(DB1, naturals100).
-%% </pre>
-%%
-%% @param Database `#database_state{}' record
-%% @param RelationName Atom naming the source relation
-%% @param N Number of elements to take
-%% @returns `{UpdatedDatabase, TakeRelation}' where TakeRelation is finite
-%%
-%% @see get_tuples_iterator/3
-take(Database, RelationName, N) when is_record(Database, database_state), is_integer(N), N > 0 ->
- case maps:get(RelationName, Database#database_state.relations, error) of
- error ->
- erlang:error({relation_not_found, RelationName});
- RelationHash ->
- [SourceRelation] = mnesia:dirty_read(relation, RelationHash),
-
- %% Compute cardinality of result
- ResultCardinality = case SourceRelation#relation.cardinality of
- {finite, M} when M < N -> {finite, M};
- {finite, _} -> {finite, N};
- _ -> {finite, N} % Taking from infinite yields finite
- end,
-
- %% Create take relation
- TakeName = list_to_atom(atom_to_list(RelationName) ++ "_take_" ++ integer_to_list(N)),
-
- TakeHash = hash({take, RelationName, N}),
-
- TakeRelation = #relation{
- hash = TakeHash,
- name = TakeName,
- tree = undefined, % Generated on-demand
- schema = SourceRelation#relation.schema,
- constraints = SourceRelation#relation.constraints,
- cardinality = ResultCardinality,
- generator = {take, RelationName, N},
- membership_criteria = #{},
- mutability = immutable, % Take relations are read-only
- provenance = {take, SourceRelation#relation.provenance, N}
- },
-
- %% Register the take relation (ephemeral - not persisted to Mnesia disk)
- %% This allows get_tuples_iterator to find it
- UpdatedDatabase = Database#database_state{
- relations = maps:put(TakeName, TakeHash, Database#database_state.relations)
- },
-
- %% Store in Mnesia (in-memory transaction)
- mnesia:dirty_write(relation, TakeRelation),
-
- {UpdatedDatabase, TakeRelation}
- end.
-
%% @doc Create an iterator for streaming tuples from a relation with constraints.
%%
%% Extended version of get_tuples_iterator/2 that accepts boundedness constraints.
@@ -818,83 +719,32 @@ take(Database, RelationName, N) when is_record(Database, database_state), is_int
%%
%% @param Database `#database_state{}' record
%% @param RelationName Atom naming the relation to iterate
-%% @param Options Map of constraints (nested or flat format)
%% @returns Pid of iterator process
%%
%% @see get_tuples_iterator/2
%% @see take/3
-get_tuples_iterator(Database, RelationName, Options) when is_record(Database, database_state) ->
+get_tuples_iterator(Database, RelationName) when is_record(Database, database_state) ->
case maps:get(RelationName, Database#database_state.relations, error) of
error ->
- erlang:error({error_tuple_iterator_init, RelationName});
+ erlang:error({error_tuple_iterator_init, relation_not_found, RelationName});
RelationHash ->
[Relation] = mnesia:dirty_read(relation, RelationHash),
-
- %% Extract constraints from options (support both nested and flat format)
- Constraints = case maps:is_key(constraints, Options) of
- true -> maps:get(constraints, Options);
- false -> Options %% Flat format: entire map is constraints
- end,
-
- %% Metadata tracking is ALWAYS enabled
case Relation#relation.cardinality of
{finite, _} when Relation#relation.tree =/= undefined ->
- %% Finite relation with materialized tuples: use tuple hashes
TupleHashes = hashes_from_tuple(RelationHash),
- spawn(fun() -> tuple_iterator_loop(TupleHashes, RelationName, true) end);
-
+ spawn(fun() -> tuple_iterator_loop(TupleHashes) end);
_ ->
- %% Relation with generator (infinite or take) or empty finite relation
- case Relation#relation.generator of
- undefined ->
- %% Empty finite relation without materialized tuples
- spawn(fun() -> tuple_iterator_loop([], RelationName, true) end);
-
- {take, SourceRelName, N} ->
- %% Take relation: get source iterator and limit to N tuples
- SourceIter = get_tuples_iterator(Database, SourceRelName, Constraints),
- spawn(fun() -> take_iter_loop(SourceIter, N, 0, N) end);
-
- {GeneratorModule, GeneratorFunction} ->
- %% Normal generator (naturals, integers, etc.)
- Generator = erlang:apply(GeneratorModule, GeneratorFunction, [Constraints]),
- spawn(fun() -> generator_iterator_loop(Generator, RelationName, true) end)
- end
+ get_iterator_from_generator(Relation#relation.name, Relation#relation.generator)
end
end.
-%% @doc Create an iterator for streaming tuples from a relation.
-%%
-%% Implements the Volcano Iterator Model: spawns an independent process that
-%% lazily streams tuples from the relation. This enables:
-%% <ul>
-%% <li>Memory-bounded queries (tuples materialized on demand)</li>
-%% <li>Composable query operators (future: select, project, join)</li>
-%% <li>Process-based concurrency</li>
-%% </ul>
-%%
-%% The iterator process responds to `{next, Caller}' messages and sends back
-%% either `{tuple, Tuple}' or `done' when exhausted.
-%%
-%% <b>Note:</b> For infinite relations, use `get_tuples_iterator/3' with constraints.
-%%
-%% == Example ==
-%% <pre>
-%% Iterator = operations:get_tuples_iterator(DB, users),
-%% {ok, Tuple} = operations:next_tuple(Iterator),
-%% operations:close_iterator(Iterator).
-%% </pre>
-%%
-%% @param Database `#database_state{}' record
-%% @param RelationName Atom naming the relation to iterate
-%% @returns Pid of iterator process
-%%
-%% @see get_tuples_iterator/3
-%% @see next_tuple/1
-%% @see close_iterator/1
-%% @see collect_all/1
-get_tuples_iterator(Database, RelationName) when is_record(Database, database_state) ->
- get_tuples_iterator(Database, RelationName, #{}).
+%% @doc Get an iterator out of a generator.
+get_iterator_from_generator(RelationName, GeneratorFun) ->
+ case GeneratorFun of
+ undefined -> spawn(fun() -> tuple_iterator_loop([]) end);
+ Generator when is_function(Generator) -> Generator();
+ _ -> erlang:error({error_tuple_iterator_init, non_function_generator, RelationName})
+ end.
%% @doc Get next tuple from iterator.
%%
@@ -946,24 +796,20 @@ close_iterator(IteratorPid) ->
%% </ul>
%%
%% @param TupleHashes List of binary tuple hashes to stream
-%% @param RelationName Name of the source relation (for provenance)
-%% @param EnableProvenance Boolean flag to enable provenance tracking
-tuple_iterator_loop([], _RelationName, _EnableProvenance) ->
+tuple_iterator_loop([]) ->
receive
{next, Caller} ->
Caller ! done;
stop ->
ok
end;
-tuple_iterator_loop([TupleHash | Rest], RelationName, EnableProvenance) ->
+tuple_iterator_loop([TupleHash | Rest]) ->
receive
{next, Caller} ->
%% Resolve tuple on demand
ResolvedTuple = resolve_tuple(TupleHash),
- %% Add provenance tracking (always enabled)
- FinalTuple = add_base_provenance(ResolvedTuple, RelationName),
- Caller ! {tuple, FinalTuple},
- tuple_iterator_loop(Rest, RelationName, EnableProvenance);
+ Caller ! {tuple, ResolvedTuple},
+ tuple_iterator_loop(Rest);
stop ->
ok
end.
@@ -1003,10 +849,6 @@ collect_all(IteratorPid, Acc) ->
{error, Reason, lists:reverse(Acc)}
end.
-%%% ============================================================================
-%%% Iterator Operators (Lazy - Volcano Model)
-%%% ============================================================================
-
%% @doc Select iterator: Filters tuples based on a predicate.
%%
%% Lazy operator that wraps a child iterator and only emits tuples that
@@ -1032,12 +874,9 @@ select_loop(ChildPid, Predicate) ->
{ok, Tuple} ->
case Predicate(Tuple) of
true ->
- %% Wrap lineage with select operation
- TupleWithLineage = wrap_lineage(Tuple, {select, Predicate}),
- Caller ! {tuple, TupleWithLineage},
+ Caller ! {tuple, Tuple},
select_loop(ChildPid, Predicate);
false ->
- %% Skip this tuple, get next
self() ! {next, Caller},
select_loop(ChildPid, Predicate)
end;
@@ -1076,30 +915,9 @@ project_loop(ChildPid, Attributes) ->
{next, Caller} ->
case next_tuple(ChildPid) of
{ok, Tuple} ->
- % Project data attributes
+ % Project data attributes only - no metadata handling
ProjectedTuple = maps:with(Attributes, Tuple),
-
- % Preserve and update metadata
- case maps:get(meta, Tuple, undefined) of
- undefined ->
- % No metadata, just return projected tuple
- Caller ! {tuple, ProjectedTuple};
- Meta ->
- % Filter provenance to only projected attributes
- OldProv = maps:get(provenance, Meta, #{}),
- NewProv = maps:with(Attributes, OldProv),
-
- % Build new metadata
- UpdatedMeta = case NewProv of
- Empty when map_size(Empty) =:= 0 -> maps:remove(provenance, Meta);
- _ -> maps:put(provenance, NewProv, Meta)
- end,
-
- % Add metadata back and wrap lineage
- TupleWithMeta = maps:put(meta, UpdatedMeta, ProjectedTuple),
- TupleWithLineage = wrap_lineage(TupleWithMeta, {project, Attributes}),
- Caller ! {tuple, TupleWithLineage}
- end,
+ Caller ! {tuple, ProjectedTuple},
project_loop(ChildPid, Attributes);
done ->
close_iterator(ChildPid),
@@ -1135,17 +953,11 @@ project_loop(ChildPid, Attributes) ->
%% @returns Iterator process (Pid)
sort_iterator(ChildIterator, CompareFun) when is_function(CompareFun, 2) ->
spawn(fun() ->
- %% Phase 1: Consume all tuples (blocking!)
+ %% Sync all state blocking the pipeline
AllTuples = collect_all(ChildIterator),
-
- %% Phase 2: Sort in memory
+ %% Sort in memory
SortedTuples = lists:sort(CompareFun, AllTuples),
-
- %% Phase 3: Wrap lineage for all tuples
- TuplesWithLineage = [wrap_lineage(T, {sort, CompareFun}) || T <- SortedTuples],
-
- %% Phase 4: Emit sorted tuples on demand
- sorted_emit_loop(TuplesWithLineage)
+ sorted_emit_loop(SortedTuples)
end).
sorted_emit_loop([]) ->
@@ -1184,9 +996,8 @@ take_iter_loop(ChildPid, Limit, Count, OriginalN) when Count < Limit ->
{next, Caller} ->
case next_tuple(ChildPid) of
{ok, Tuple} ->
- %% Wrap lineage with take operation
- TupleWithLineage = wrap_lineage(Tuple, {take, OriginalN}),
- Caller ! {tuple, TupleWithLineage},
+ %% Pass through tuple without metadata injection
+ Caller ! {tuple, Tuple},
take_iter_loop(ChildPid, Limit, Count + 1, OriginalN);
done ->
close_iterator(ChildPid),
@@ -1305,7 +1116,7 @@ materialize(Database, SourceIterator, ResultName) when is_record(Database, datab
{DB1, _Relation} = create_relation(Database, ResultName, Schema),
%% 4. Insert all tuples
- {FinalDB, FinalRelation} = insert_all_tuples(DB1, ResultName, AllTuples),
+ {FinalDB, FinalRelation} = create_all_tuples(DB1, ResultName, AllTuples),
{FinalDB, FinalRelation}
end.
@@ -1327,7 +1138,7 @@ infer_type(_) -> term.
%% @private
%% Insert multiple tuples into a relation.
-insert_all_tuples(DB, RelationName, Tuples) ->
+create_all_tuples(DB, RelationName, Tuples) ->
lists:foldl(
fun(Tuple, {DBacc, _RelAcc}) ->
create_tuple(DBacc, RelationName, Tuple)
@@ -1340,51 +1151,20 @@ insert_all_tuples(DB, RelationName, Tuples) ->
%%% Generator Support Functions
%%% ============================================================================
-%% Instantiate a generator from a generator specification (commented out).
-%% Converts a generator spec (stored in relation record) into an actual
-%% generator function that can produce tuples.
-%%
-%% @param GeneratorSpec Generator specification
-%% @param Constraints Boundedness constraints
-%% @returns Generator function
-%% instantiate_generator({primitive, naturals}, Constraints) ->
-%% generators:naturals(Constraints);
-%% instantiate_generator({primitive, integers}, Constraints) ->
-%% generators:integers(Constraints);
-%% instantiate_generator({primitive, rationals}, Constraints) ->
-%% generators:rationals(Constraints);
-%% instantiate_generator({primitive, plus}, Constraints) ->
-%% generators:plus(Constraints);
-%% instantiate_generator({primitive, times}, Constraints) ->
-%% generators:times(Constraints);
-%% instantiate_generator({primitive, minus}, Constraints) ->
-%% generators:minus(Constraints);
-%% instantiate_generator({primitive, divide}, Constraints) ->
-%% generators:divide(Constraints);
-%% instantiate_generator({custom, GeneratorFun}, Constraints) ->
-%% GeneratorFun(Constraints);
-%% instantiate_generator({take, RelationName, N}, Constraints) ->
-%% %% Take generator: wrap another generator with limit
-%% %% This is a simplified version - full implementation would look up the base relation
-%% make_take_generator(RelationName, N, Constraints);
-%% instantiate_generator(Other, _) ->
-%% erlang:error({unknown_generator, Other}).
%% @private
%% Generator iterator loop for infinite relations.
%% Similar to tuple_iterator_loop but works with generator functions instead
%% of tuple hashes.
-generator_iterator_loop(Generator, RelationName, EnableProvenance) ->
+generator_iterator_loop(Generator) ->
receive
{next, Caller} ->
case Generator(next) of
done ->
Caller ! done;
- {value, Tuple, NextGen} ->
- %% Add provenance tracking (always enabled)
- FinalTuple = add_base_provenance(Tuple, RelationName),
- Caller ! {tuple, FinalTuple},
- generator_iterator_loop(NextGen, RelationName, EnableProvenance);
+ {value, Tuple, NextGenerator} ->
+ Caller ! {tuple, Tuple},
+ generator_iterator_loop(NextGenerator);
{error, Reason} ->
Caller ! {error, Reason}
end;
@@ -1423,7 +1203,7 @@ equijoin_emit_loop(LeftIter, RightTuples, JoinAttr) ->
equijoin_emit_loop(LeftIter, RightTuples, JoinAttr);
LeftValue ->
% Find matching right tuples
- Matches = [merge_tuples(LeftTuple, RightTuple, {equijoin, JoinAttr})
+ Matches = [merge_tuples(LeftTuple, RightTuple)
|| RightTuple <- RightTuples,
maps:get(JoinAttr, RightTuple, undefined) =:= LeftValue],
case Matches of
@@ -1478,7 +1258,7 @@ theta_join_emit_loop(LeftIter, RightTuples, Pred) ->
Caller ! done;
{ok, LeftTuple} ->
% Find all right tuples that satisfy predicate
- Matches = [merge_tuples(LeftTuple, RightTuple, {theta_join, Pred})
+ Matches = [merge_tuples(LeftTuple, RightTuple)
|| RightTuple <- RightTuples,
Pred(LeftTuple, RightTuple)],
case Matches of
@@ -1512,133 +1292,64 @@ theta_join_emit_buffered(LeftIter, RightTuples, Pred, []) ->
theta_join_emit_loop(LeftIter, RightTuples, Pred).
%% @private
-%% Wrap existing lineage with a new operation.
-%% Takes a tuple with optional lineage and wraps it with a new operation node.
-%% If the tuple has no lineage, does nothing.
-%%
-%% @param Tuple Tuple potentially with meta.lineage
-%% @param OpInfo Operation info: {select, Pred} | {project, Attrs} | {sort, Cmp} | {take, N}
-wrap_lineage(Tuple, OpInfo) ->
- case maps:get(meta, Tuple, undefined) of
- undefined ->
- % No metadata, return as-is
- Tuple;
- Meta ->
- case maps:get(lineage, Meta, undefined) of
+%% @doc Simple tuple merge for joins
+%% Equally named and typed attributes are not repeated.
+%%
+%% @param LeftTuple Left side tuple
+%% @param RightTuple Right side tuple
+merge_tuples(LeftTuple, RightTuple) ->
+ maps:fold(
+ fun(RightKey, RightValue, LeftTupleAcc) ->
+ case maps:get(RightKey, LeftTupleAcc, undefined) of
+ LeftValue when LeftValue =:= RightValue ->
+ % Equal attributes, keep single value
+ LeftTupleAcc;
undefined ->
- % No lineage, return as-is
- Tuple;
- ChildLineage ->
- % Wrap existing lineage with new operation
- NewLineage = case OpInfo of
- {select, Pred} -> {select, Pred, ChildLineage};
- {project, Attrs} -> {project, Attrs, ChildLineage};
- {sort, CmpFun} -> {sort, CmpFun, ChildLineage};
- {take, N} -> {take, N, ChildLineage}
- end,
- maps:put(meta, maps:put(lineage, NewLineage, Meta), Tuple)
+ % No conflict, add right attribute
+ maps:put(RightKey, RightValue, LeftTupleAcc);
+ _ ->
+ % Different attributes
+ maps:put(RightKey, RightValue, LeftTupleAcc)
end
- end.
-
-%% @private
-%% Add base provenance and lineage to a tuple from a relation.
-%% Creates nested meta fields with both provenance and lineage.
-%% Example: add_base_provenance(#{id => 1, name => "Alice"}, employees)
-%% => #{id => 1, name => "Alice",
-%% meta => #{provenance => #{id => {employees, id}, name => {employees, name}},
-%% lineage => {base, employees}}}
-add_base_provenance(Tuple, RelationName) ->
- % Build provenance map for all attributes (excluding existing meta)
- DataAttrs = maps:remove(meta, Tuple),
- Provenance = maps:fold(
- fun(Key, _Value, Acc) ->
- maps:put(Key, {RelationName, Key}, Acc)
end,
- #{},
- DataAttrs
- ),
- % Add metadata with provenance and lineage
- maps:put(meta, #{
- provenance => Provenance,
- lineage => {base, RelationName}
- }, Tuple).
+ LeftTuple,
+ RightTuple
+ ).
%% @private
-%% Merge two tuples for join result, including metadata.
-%% If attribute names conflict, right side attributes are prefixed with "right_".
-%% Metadata (including provenance and lineage) is also merged.
-%%
-%% @param LeftTuple Left side tuple
-%% @param RightTuple Right side tuple
-%% @param JoinInfo Join information: {equijoin, Attr} | {theta_join, Predicate}
-merge_tuples(LeftTuple, RightTuple, JoinInfo) ->
- % Extract metadata from both sides (if present)
- LeftMeta = maps:get(meta, LeftTuple, #{}),
- RightMeta = maps:get(meta, RightTuple, #{}),
-
- % Extract provenance and lineage from metadata
- LeftProv = maps:get(provenance, LeftMeta, #{}),
- RightProv = maps:get(provenance, RightMeta, #{}),
- LeftLineage = maps:get(lineage, LeftMeta, undefined),
- RightLineage = maps:get(lineage, RightMeta, undefined),
-
- % Remove metadata from tuples for data merging
- LeftData = maps:remove(meta, LeftTuple),
- RightData = maps:remove(meta, RightTuple),
-
- % Merge data attributes
- MergedData = maps:fold(
- fun(Key, Value, Acc) ->
- case maps:is_key(Key, Acc) of
- true ->
- % Conflict - prefix right attribute
- RightKey = list_to_atom("right_" ++ atom_to_list(Key)),
- maps:put(RightKey, Value, Acc);
- false ->
- maps:put(Key, Value, Acc)
- end
- end,
- LeftData,
- RightData
- ),
-
- % Merge provenance (same conflict resolution)
- MergedProv = maps:fold(
- fun(Key, Source, Acc) ->
- case maps:is_key(Key, Acc) of
- true ->
- % Conflict - prefix right attribute provenance
- RightKey = list_to_atom("right_" ++ atom_to_list(Key)),
- maps:put(RightKey, Source, Acc);
- false ->
- maps:put(Key, Source, Acc)
- end
+%% Build base provenance mapping for a relation.
+%% Maps each attribute to its source relation and attribute name.
+%%
+%% @param Schema Map of attribute names to types
+%% @param RelationName Name of the source relation
+%% @returns Provenance map #{attr => {relation, attr}}
+build_base_provenance(Schema, RelationName) ->
+ maps:fold(
+ fun(AttrName, _Type, Acc) ->
+ maps:put(AttrName, {RelationName, AttrName}, Acc)
end,
- LeftProv,
- RightProv
- ),
-
- % Build merged lineage if both sides have lineage
- MergedLineage = case {LeftLineage, RightLineage, JoinInfo} of
- {undefined, undefined, _} -> undefined;
- {L, R, {equijoin, Attr}} when L =/= undefined, R =/= undefined ->
- {join, Attr, L, R};
- {L, R, {theta_join, Pred}} when L =/= undefined, R =/= undefined ->
- {theta_join, Pred, L, R};
- _ -> undefined
- end,
+ #{},
+ Schema
+ ).
- % Build merged metadata
- MergedMeta = case {maps:size(MergedProv), MergedLineage} of
- {0, undefined} -> #{};
- {0, Lineage} -> #{lineage => Lineage};
- {_, undefined} -> #{provenance => MergedProv};
- {_, Lineage} -> #{provenance => MergedProv, lineage => Lineage}
- end,
+%% The issue here is that the creation of a generator on the create_relation is wrong.
+%% I assume it was because of the fun(Constraints), which I believe should receive an
+%% optional database version, or just build from the current relation attributes as a factory.
- % Add merged metadata to result (only if non-empty)
- case maps:size(MergedMeta) of
- 0 -> MergedData;
- _ -> maps:put(meta, MergedMeta, MergedData)
+%% @private
+%% @doc Create a generator function from a list of tuple hashes.
+%%
+%% Converts a list of tuple hashes into a generator function compatible
+%% with the generator protocol used by infinite relations. The generator
+%% reads tuples from Mnesia on demand.
+%%
+%% @param TupleHashes List of tuple hashes to iterate over
+%% @returns Generator function that accepts 'next' and returns {value, Tuple, NextGen} or done
+make_finite_generator([]) ->
+ fun(_) -> done end;
+make_finite_generator([Hash | Rest]) ->
+ fun(next) ->
+ ResolvedTuple = resolve_tuple(Hash),
+ NextGen = make_finite_generator(Rest),
+ {value, ResolvedTuple, NextGen}
end.
-
diff --git a/src/query_planner.erl b/src/query_planner.erl
index 9d9d2bc..52bffda 100644
--- a/src/query_planner.erl
+++ b/src/query_planner.erl
@@ -8,43 +8,37 @@
%%%
%%% The DSL uses nested tuples to represent relational operations:
%%% <pre>
-%%% % Scan relation (base case)
-%%% {scan, employees}
-%%%
-%%% % Reference relation (synonym for scan)
-%%% {relation, employees}
+%%% % Base relation (just use the relation name)
+%%% employees
%%%
%%% % Selection (filter)
-%%% {select, {scan, employees}, fun(T) -&gt; maps:get(age, T) &gt; 30 end}
+%%% {select, employees, fun(T) -&gt; maps:get(age, T) &gt; 30 end}
%%%
%%% % Projection
-%%% {project, {scan, employees}, [name, age]}
+%%% {project, employees, [name, age]}
%%%
%%% % Equijoin (natural join on common attribute)
-%%% {join, {scan, employees}, {scan, departments}, dept_id}
+%%% {join, employees, departments, dept_id}
%%%
%%% % Theta join (join with arbitrary predicate)
%%% {theta_join,
-%%% {scan, employees},
-%%% {scan, departments},
+%%% employees,
+%%% departments,
%%% fun(L, R) -&gt; maps:get(dept_id, L) =:= maps:get(id, R) end}
%%%
%%% % Sort
-%%% {sort, {scan, employees}, fun(A, B) -&gt; maps:get(age, A) =&lt; maps:get(age, B) end}
+%%% {sort, employees, fun(A, B) -&gt; maps:get(age, A) =&lt; maps:get(age, B) end}
%%%
%%% % Take (limit)
-%%% {take, {scan, employees}, 10}
+%%% {take, employees, 10}
%%%
%%% % Rename attribute
-%%% {rename, old_name, new_name, {scan, employees}}
+%%% {rename, old_name, new_name, employees}
%%%
%%% % Composite query
%%% {project,
%%% {select,
-%%% {join,
-%%% {scan, employees},
-%%% {scan, departments},
-%%% dept_id},
+%%% {join, employees, departments, dept_id},
%%% fun(T) -&gt; maps:get(age, T) &gt; 30 end},
%%% [name, dept_name]}
%%% </pre>
@@ -52,7 +46,7 @@
%%% == Usage ==
%%% <pre>
%%% % Prepare a query plan
-%%% Plan = query_planner:prepare({select, {scan, employees},
+%%% Plan = query_planner:prepare({select, employees,
%%% fun(T) -&gt; maps:get(age, T) &gt; 30 end}).
%%%
%%% % Execute and get iterator
@@ -77,8 +71,7 @@
%%% Types
-type query_plan() ::
- {scan, atom()} % Scan relation
- | {relation, atom()} % Reference relation
+ atom() % Relation name
| {select, query_plan(), fun((map()) -> boolean())} % Filter
| {project, query_plan(), [atom()]} % Projection
| {join, query_plan(), query_plan(), atom()} % Equijoin
@@ -111,21 +104,22 @@ prepare(Plan) ->
%% @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.
+%% Returns an ephemeral relation for top-level operations that can spawn
+%% multiple independent iterators. The relation's generator creates fresh
+%% iterator pipelines on demand, allowing reproducible results.
%%
%% @param DB The database state
%% @param Plan The prepared query plan
-%% @returns Iterator process PID
--spec execute(term(), query_plan()) -> pid().
+%% @returns Ephemeral relation record
+-spec execute(term(), query_plan()) -> #relation{}.
execute(DB, Plan) ->
- compile_to_iterator(DB, Plan).
+ create_ephemeral_relation(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.
+%% tuples into a list. Uses the ephemeral relation to create an
+%% iterator and automatically closes it.
%%
%% If the iterator times out (e.g., when sorting an infinite relation),
%% returns partial results with a warning.
@@ -135,7 +129,8 @@ execute(DB, Plan) ->
%% @returns List of result tuples, or partial results on timeout
-spec collect(term(), query_plan()) -> [map()].
collect(DB, Plan) ->
- Iter = execute(DB, Plan),
+ EphemeralRelation = execute(DB, Plan),
+ Iter = (EphemeralRelation#relation.generator)(),
Results = operations:collect_all(Iter),
operations:close_iterator(Iter),
case Results of
@@ -165,11 +160,188 @@ explain(Plan) ->
%%% Internal Functions
+%% @doc Create an ephemeral relation for the top-level query plan.
+%%
+%% Uses pure relational operators from relational_operators module to construct
+%% the query result. Returns an ephemeral relation with a generator closure that
+%% can spawn fresh iterators, allowing reproducible query results.
+%%
+%% @param DB The database state (for looking up base relations only)
+%% @param Plan The query plan
+%% @returns Ephemeral relation record
+-spec create_ephemeral_relation(term(), query_plan()) -> #relation{}.
+create_ephemeral_relation(DB, Plan) ->
+ % Compile plan to ephemeral relation using pure operators
+ compile_to_relation(DB, Plan).
+
+%% @doc Compute the schema for a query plan.
+%%
+%% Recursively analyzes the query plan structure to determine the output schema.
+%% The schema maps attribute names to their domain/type information.
+%%
+%% @param DB The database state
+%% @param Plan The query plan
+%% @returns Schema map
+-spec compute_schema(term(), query_plan()) -> map().
+compute_schema(DB, RelationName) when is_atom(RelationName) ->
+ case maps:get(RelationName, DB#database_state.relations, undefined) of
+ undefined ->
+ % Relation not found, return empty schema
+ #{};
+ RelationHash ->
+ [Relation] = mnesia:dirty_read(relation, RelationHash),
+ Relation#relation.schema
+ end;
+
+compute_schema(DB, {select, SubPlan, _Predicate}) ->
+ % Select doesn't change schema, just filters tuples
+ compute_schema(DB, SubPlan);
+
+compute_schema(DB, {project, SubPlan, Attributes}) ->
+ % Project filters the schema to only include selected attributes
+ SubSchema = compute_schema(DB, SubPlan),
+ maps:with(Attributes, SubSchema);
+
+compute_schema(DB, {join, LeftPlan, RightPlan, _Attribute}) ->
+ % Join combines schemas from both sides, handling attribute conflicts
+ LeftSchema = compute_schema(DB, LeftPlan),
+ RightSchema = compute_schema(DB, RightPlan),
+ merge_join_schemas(LeftSchema, RightSchema);
+
+compute_schema(DB, {theta_join, LeftPlan, RightPlan, _Predicate}) ->
+ % Theta join also combines schemas from both sides, handling attribute conflicts
+ LeftSchema = compute_schema(DB, LeftPlan),
+ RightSchema = compute_schema(DB, RightPlan),
+ merge_join_schemas(LeftSchema, RightSchema);
+
+compute_schema(DB, {sort, SubPlan, _Comparator}) ->
+ % Sort doesn't change schema
+ compute_schema(DB, SubPlan);
+
+compute_schema(DB, {take, SubPlan, _N}) ->
+ % Take doesn't change schema, just limits tuples
+ compute_schema(DB, SubPlan);
+
+compute_schema(DB, {rename, OldAttr, NewAttr, SubPlan}) ->
+ % Rename changes one attribute name in the schema
+ SubSchema = compute_schema(DB, SubPlan),
+ case maps:is_key(OldAttr, SubSchema) of
+ true ->
+ AttrType = maps:get(OldAttr, SubSchema),
+ SubSchema1 = maps:remove(OldAttr, SubSchema),
+ maps:put(NewAttr, AttrType, SubSchema1);
+ false ->
+ % Attribute doesn't exist, return unchanged schema
+ SubSchema
+ end;
+
+compute_schema(DB, {materialize, SubPlan}) ->
+ % Materialize doesn't change schema
+ compute_schema(DB, SubPlan);
+
+compute_schema(DB, {materialize, SubPlan, _N}) ->
+ % Materialize with limit doesn't change schema
+ compute_schema(DB, SubPlan);
+
+compute_schema(_DB, _UnknownPlan) ->
+ % Unknown plan type, return empty schema
+ #{}.
+
+%% @doc Merge schemas for join operations, handling attribute conflicts elegantly.
+%%
+%% Instead of prefixing conflicting attribute names, this approach maintains
+%% both provenances in the schema value. For attributes that exist in both
+%% relations, the schema value becomes a list of the original values.
+%% This is especially important for natural joins where the join attribute
+%% logically represents the same entity from both sides.
+%%
+%% @param LeftSchema Schema from left relation
+%% @param RightSchema Schema from right relation
+%% @returns Merged schema map with provenance lists for conflicts
+-spec merge_join_schemas(map(), map()) -> map().
+merge_join_schemas(LeftSchema, RightSchema) ->
+ maps:fold(
+ fun(Key, RightValue, Acc) ->
+ case maps:get(Key, Acc, undefined) of
+ undefined ->
+ % No conflict, add the right attribute
+ maps:put(Key, RightValue, Acc);
+ LeftValue when LeftValue =:= RightValue ->
+ % Same type/domain, keep single value (common in natural joins)
+ maps:put(Key, LeftValue, Acc);
+ LeftValue when is_list(LeftValue) ->
+ % Left value is already a list, append if not already present
+ case lists:member(RightValue, LeftValue) of
+ true -> maps:put(Key, LeftValue, Acc);
+ false -> maps:put(Key, LeftValue ++ [RightValue], Acc)
+ end;
+ LeftValue ->
+ % Conflict with different types - create provenance list
+ maps:put(Key, [LeftValue, RightValue], Acc)
+ end
+ end,
+ LeftSchema,
+ RightSchema
+ ).
+
+%% @doc Compute provenance for a query plan.
+%%
+%% Recursively analyzes the query plan structure to determine the provenance,
+%% following the provenance types defined in operations.hrl.
+%%
+%% @param Plan The query plan
+%% @returns Provenance term
+-spec compute_provenance(query_plan()) -> term().
+compute_provenance(RelationName) when is_atom(RelationName) ->
+ {base, RelationName};
+
+compute_provenance({select, SubPlan, _Predicate}) ->
+ % For select, we'd need to represent the predicate constraints
+ % For now, simplified without constraint details
+ SubProvenance = compute_provenance(SubPlan),
+ {select, SubProvenance, #{}};
+
+compute_provenance({project, SubPlan, Attributes}) ->
+ SubProvenance = compute_provenance(SubPlan),
+ {project, SubProvenance, Attributes};
+
+compute_provenance({join, LeftPlan, RightPlan, _Attribute}) ->
+ LeftProvenance = compute_provenance(LeftPlan),
+ RightProvenance = compute_provenance(RightPlan),
+ {join, LeftProvenance, RightProvenance};
+
+compute_provenance({theta_join, LeftPlan, RightPlan, _Predicate}) ->
+ LeftProvenance = compute_provenance(LeftPlan),
+ RightProvenance = compute_provenance(RightPlan),
+ {join, LeftProvenance, RightProvenance};
+
+compute_provenance({sort, SubPlan, _Comparator}) ->
+ % Sort doesn't change provenance
+ compute_provenance(SubPlan);
+
+compute_provenance({take, SubPlan, N}) ->
+ SubProvenance = compute_provenance(SubPlan),
+ {take, SubProvenance, N};
+
+compute_provenance({rename, _OldAttr, _NewAttr, SubPlan}) ->
+ % Rename preserves underlying provenance
+ compute_provenance(SubPlan);
+
+compute_provenance({materialize, SubPlan}) ->
+ % Materialize doesn't change provenance
+ compute_provenance(SubPlan);
+
+compute_provenance({materialize, SubPlan, _N}) ->
+ % Materialize with limit doesn't change provenance
+ compute_provenance(SubPlan);
+
+compute_provenance(_UnknownPlan) ->
+ % Unknown plan type
+ undefined.
+
%% 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) ->
+validate_plan(Name) when is_atom(Name) ->
ok;
validate_plan({select, SubPlan, Pred}) when is_function(Pred, 1) ->
validate_plan(SubPlan);
@@ -205,10 +377,7 @@ validate_plan(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}) ->
+compile_to_iterator(DB, Name) when is_atom(Name) ->
operations:get_tuples_iterator(DB, Name);
compile_to_iterator(DB, {select, SubPlan, Predicate}) ->
@@ -234,22 +403,16 @@ compile_to_iterator(DB, {sort, SubPlan, Comparator}) ->
operations:sort_iterator(SubIter, Comparator);
compile_to_iterator(DB, {take, SubPlan, N}) ->
- %% Use relational take operator when possible (for scans)
- case SubPlan of
- {scan, RelationName} ->
- %% Direct scan: use relational take operator
- {DB2, TakeRelation} = operations:take(DB, RelationName, N),
- %% Get iterator from the take relation (now properly registered)
- operations:get_tuples_iterator(DB2, TakeRelation#relation.name, #{});
- _ ->
- %% Complex plan: fall back to iterator-based take for composition
- SubIter = compile_to_iterator(DB, SubPlan),
- operations:take_iterator(SubIter, N)
- end;
+ %% For iterator-based compilation, use take_iterator
+ 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);
+ %% Inline simple rename - no need for separate function
+ spawn(fun() ->
+ rename_loop_inline(SubIter, OldAttr, NewAttr)
+ end);
compile_to_iterator(DB, {materialize, SubPlan}) ->
% Materialize returns all tuples as a "static" iterator
@@ -286,18 +449,14 @@ static_loop(Tuples) ->
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) ->
+%% @private
+%% Inline helper for compile_to_iterator rename case
+rename_loop_inline(SourceIter, OldAttr, NewAttr) ->
receive
{next, Caller} ->
case operations:next_tuple(SourceIter) of
done ->
- Caller ! done,
- rename_loop(SourceIter, OldAttr, NewAttr);
+ Caller ! done;
{ok, Tuple} ->
RenamedTuple = case maps:is_key(OldAttr, Tuple) of
true ->
@@ -308,7 +467,7 @@ rename_loop(SourceIter, OldAttr, NewAttr) ->
Tuple
end,
Caller ! {tuple, RenamedTuple},
- rename_loop(SourceIter, OldAttr, NewAttr)
+ rename_loop_inline(SourceIter, OldAttr, NewAttr)
end;
{close, Caller} ->
operations:close_iterator(SourceIter),
@@ -317,10 +476,7 @@ rename_loop(SourceIter, OldAttr, NewAttr) ->
%% 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) ->
+explain_plan(Name, Indent) when is_atom(Name) ->
indent(Indent) ++ "Relation: " ++ atom_to_list(Name) ++ "\n";
explain_plan({select, SubPlan, _Pred}, Indent) ->
@@ -365,3 +521,81 @@ explain_plan({materialize, SubPlan, N}, Indent) ->
%% Helper: indent string
-spec indent(non_neg_integer()) -> string().
indent(N) -> lists:duplicate(N, $\s).
+
+%% Helper: get relation from database
+-spec get_relation_from_db(term(), atom()) -> #relation{}.
+get_relation_from_db(DB, RelationName) ->
+ case maps:get(RelationName, DB#database_state.relations, undefined) of
+ undefined ->
+ erlang:error({relation_not_found, RelationName});
+ RelationHash ->
+ [Relation] = mnesia:dirty_read(relation, RelationHash),
+ Relation
+ end.
+
+%% @doc Compile query plan to ephemeral relation using pure relational operators.
+%%
+%% This is the new approach: build a relation tree using pure operators,
+%% then return the final ephemeral relation. No iterators are created until
+%% the generator is called.
+-spec compile_to_relation(term(), query_plan()) -> #relation{}.
+compile_to_relation(DB, RelationName) when is_atom(RelationName) ->
+ BaseRelation = get_relation_from_db(DB, RelationName),
+ GeneratorFun = fun() ->
+ operations:get_tuples_iterator(DB, RelationName)
+ end,
+ BaseRelation#relation{generator = GeneratorFun};
+
+compile_to_relation(DB, {select, SubPlan, Predicate}) ->
+ SubRelation = compile_to_relation(DB, SubPlan),
+ relational_operators:select(SubRelation, Predicate);
+
+compile_to_relation(DB, {project, SubPlan, Attributes}) ->
+ SubRelation = compile_to_relation(DB, SubPlan),
+ relational_operators:project(SubRelation, Attributes);
+
+compile_to_relation(DB, {join, LeftPlan, RightPlan, Attribute}) ->
+ LeftRelation = compile_to_relation(DB, LeftPlan),
+ RightRelation = compile_to_relation(DB, RightPlan),
+ relational_operators:join(LeftRelation, RightRelation, Attribute);
+
+compile_to_relation(DB, {theta_join, LeftPlan, RightPlan, Predicate}) ->
+ LeftRelation = compile_to_relation(DB, LeftPlan),
+ RightRelation = compile_to_relation(DB, RightPlan),
+ relational_operators:theta_join(LeftRelation, RightRelation, Predicate);
+
+compile_to_relation(DB, {sort, SubPlan, Comparator}) ->
+ SubRelation = compile_to_relation(DB, SubPlan),
+ relational_operators:sort(SubRelation, Comparator);
+
+compile_to_relation(DB, {take, SubPlan, N}) ->
+ SubRelation = compile_to_relation(DB, SubPlan),
+ relational_operators:take(SubRelation, N);
+
+compile_to_relation(DB, {rename, OldAttr, NewAttr, SubPlan}) ->
+ SubRelation = compile_to_relation(DB, SubPlan),
+ relational_operators:rename(SubRelation, #{OldAttr => NewAttr});
+
+compile_to_relation(DB, {materialize, SubPlan}) ->
+ GeneratorFun = fun() ->
+ compile_to_iterator(DB, SubPlan)
+ end,
+ EphemeralName = list_to_atom("materialized_" ++ integer_to_list(erlang:unique_integer([positive]))),
+ #relation{
+ hash = crypto:hash(sha256, term_to_binary({materialize, SubPlan})),
+ name = EphemeralName,
+ tree = undefined,
+ schema = compute_schema(DB, SubPlan),
+ constraints = #{},
+ cardinality = unknown,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = compute_provenance(SubPlan)
+ };
+
+compile_to_relation(DB, {materialize, SubPlan, N}) ->
+ % Materialize with limit
+ SubRelation = compile_to_relation(DB, SubPlan),
+ TakeRelation = relational_operators:take(SubRelation, N),
+ % Then wrap in a materialized form (for now just return take)
+ TakeRelation.
diff --git a/src/relational_operators.erl b/src/relational_operators.erl
new file mode 100644
index 0000000..82e1a47
--- /dev/null
+++ b/src/relational_operators.erl
@@ -0,0 +1,366 @@
+%%% @doc Pure Relational Operators
+%%%
+%%% This module provides pure relational algebra operators that take relations
+%%% as input and return new ephemeral relations as output. No database state
+%%% is modified. Each operator returns a relation with a generator closure that
+%%% can spawn fresh iterators on demand, allowing reproducible query results.
+%%%
+%%% == Architecture ==
+%%%
+%%% Relational Operators (Pure Functions):
+%%% - Input: One or more #relation{} records
+%%% - Output: New ephemeral #relation{} with generator closure
+%%% - No side effects, no DB modification
+%%% - Generator can be called multiple times for same results
+%%%
+%%% Iterator Operators (Data Transport):
+%%% - Input: Iterator PIDs
+%%% - Output: Iterator PID
+%%% - Used inside generator closures
+%%% - Examples: select_iterator/2, project_iterator/2, take_iterator/2
+%%%
+%%% @author Nekoma Team
+%%% @copyright 2025
+
+-module(relational_operators).
+
+-include("../include/operations.hrl").
+
+-export([
+ take/2,
+ select/2,
+ project/2,
+ join/3,
+ theta_join/3,
+ rename/2,
+ sort/2
+]).
+
+%%% ============================================================================
+%%% Internal Helper Functions
+%%% ============================================================================
+
+%% @private
+%% Generate a random name for ephemeral relations.
+generate_ephemeral_name() ->
+ {UUID, _} = uuid:get_v1(uuid:new(self(), erlang)),
+ [Base64UUID] = io_lib:format("~s", [base64:encode(UUID)]),
+ Base64UUID.
+
+%%% ============================================================================
+%%% Relational Operators
+%%% ============================================================================
+
+%% @doc Take operator (τ): Returns ephemeral relation with first N tuples.
+%%
+%% Creates an ephemeral relation containing N arbitrary elements from the
+%% source relation. The generator closure captures the source relation and N,
+%% spawning fresh iterators on each call.
+%%
+%% == Example ==
+%% <pre>
+%% NaturalRel = get_relation(DB, natural),
+%% Some100 = relational_operators:take(NaturalRel, 100),
+%%
+%% %% Can call generator multiple times
+%% Iter1 = operations:get_iterator_from_generator(ephemeral, Some100#relation.generator),
+%% Results1 = operations:collect_all(Iter1),
+%%
+%% Iter2 = operations:get_iterator_from_generator(ephemeral, Some100#relation.generator),
+%% Results2 = operations:collect_all(Iter2),
+%% %% Results1 =:= Results2
+%% </pre>
+%%
+%% @param SourceRelation Source #relation{} record
+%% @param N Number of tuples to take
+%% @returns Ephemeral #relation{} with generator
+-spec take(#relation{}, pos_integer()) -> #relation{}.
+take(SourceRelation, N) when is_record(SourceRelation, relation), is_integer(N), N > 0 ->
+ %% Compute result cardinality
+ ResultCardinality = case SourceRelation#relation.cardinality of
+ {finite, M} when M < N -> {finite, M};
+ {finite, _} -> {finite, N};
+ _ -> {finite, N}
+ end,
+ Name = generate_ephemeral_name(),
+ SourceGen = SourceRelation#relation.generator,
+ GeneratorFun = fun() ->
+ SourceIter = SourceGen(),
+ operations:take_iterator(SourceIter, N)
+ end,
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = SourceRelation#relation.schema,
+ constraints = SourceRelation#relation.constraints,
+ cardinality = ResultCardinality,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = SourceRelation#relation.provenance,
+ lineage = {take, N, SourceRelation#relation.lineage}
+ }.
+
+%% @doc Select operator (σ): Returns ephemeral relation filtered by predicate.
+%%
+%% Creates an ephemeral relation containing only tuples that satisfy the
+%% predicate. The generator closure captures the source relation and predicate.
+%%
+%% @param SourceRelation Source #relation{} record
+%% @param Predicate Function that takes a tuple and returns boolean
+%% @returns Ephemeral #relation{} with generator
+-spec select(#relation{}, fun((map()) -> boolean())) -> #relation{}.
+select(SourceRelation, Predicate) when is_record(SourceRelation, relation), is_function(Predicate, 1) ->
+ Name = generate_ephemeral_name(),
+
+ SourceGen = SourceRelation#relation.generator,
+ GeneratorFun = fun() ->
+ SourceIter = SourceGen(),
+ operations:select_iterator(SourceIter, Predicate)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = SourceRelation#relation.schema,
+ constraints = SourceRelation#relation.constraints,
+ cardinality = unknown, % Cannot determine without evaluation
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = SourceRelation#relation.provenance,
+ lineage = {select, Predicate, SourceRelation#relation.lineage}
+ }.
+
+%% @doc Project operator (π): Returns ephemeral relation with selected attributes.
+%%
+%% Creates an ephemeral relation containing only the specified attributes.
+%% The generator closure captures the source relation and attribute list.
+%%
+%% @param SourceRelation Source #relation{} record
+%% @param Attributes List of attribute names to keep
+%% @returns Ephemeral #relation{} with generator
+-spec project(#relation{}, [atom()]) -> #relation{}.
+project(SourceRelation, Attributes) when is_record(SourceRelation, relation), is_list(Attributes) ->
+ Name = generate_ephemeral_name(),
+
+ %% Compute projected schema
+ ProjectedSchema = maps:with(Attributes, SourceRelation#relation.schema),
+
+ SourceGen = SourceRelation#relation.generator,
+ GeneratorFun = fun() ->
+ SourceIter = SourceGen(),
+ operations:project_iterator(SourceIter, Attributes)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = ProjectedSchema,
+ constraints = SourceRelation#relation.constraints,
+ cardinality = SourceRelation#relation.cardinality,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = SourceRelation#relation.provenance,
+ lineage = {project, Attributes, SourceRelation#relation.lineage}
+ }.
+
+%% @doc Join operator (⋈): Returns ephemeral relation from equijoin.
+%%
+%% Creates an ephemeral relation by joining two relations on a common attribute.
+%% The generator closure captures both source relations and the join attribute.
+%%
+%% @param LeftRelation Left #relation{} record
+%% @param RightRelation Right #relation{} record
+%% @param JoinAttribute Attribute name to join on
+%% @returns Ephemeral #relation{} with generator
+-spec join(#relation{}, #relation{}, atom()) -> #relation{}.
+join(LeftRelation, RightRelation, JoinAttribute)
+ when is_record(LeftRelation, relation),
+ is_record(RightRelation, relation),
+ is_atom(JoinAttribute) ->
+
+ Name = list_to_atom(
+ "join_" ++ atom_to_list(LeftRelation#relation.name) ++ "_" ++
+ atom_to_list(RightRelation#relation.name) ++ "_" ++
+ integer_to_list(erlang:unique_integer([positive]))
+ ),
+
+ %% Merge schemas
+ MergedSchema = maps:merge(LeftRelation#relation.schema, RightRelation#relation.schema),
+
+ LeftGen = LeftRelation#relation.generator,
+ RightGen = RightRelation#relation.generator,
+ GeneratorFun = fun() ->
+ LeftIter = LeftGen(),
+ RightIter = RightGen(),
+ operations:equijoin_iterator(LeftIter, RightIter, JoinAttribute)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = MergedSchema,
+ constraints = #{},
+ cardinality = unknown,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = {join, LeftRelation#relation.provenance, RightRelation#relation.provenance},
+ lineage = {join, JoinAttribute, LeftRelation#relation.lineage, RightRelation#relation.lineage}
+ }.
+
+%% @doc Theta Join operator (⋈θ): Returns ephemeral relation from theta join.
+%%
+%% Creates an ephemeral relation by joining two relations with an arbitrary
+%% predicate. The generator closure captures both source relations and predicate.
+%%
+%% @param LeftRelation Left #relation{} record
+%% @param RightRelation Right #relation{} record
+%% @param Predicate Function that takes two tuples and returns boolean
+%% @returns Ephemeral #relation{} with generator
+-spec theta_join(#relation{}, #relation{}, fun((map(), map()) -> boolean())) -> #relation{}.
+theta_join(LeftRelation, RightRelation, Predicate)
+ when is_record(LeftRelation, relation),
+ is_record(RightRelation, relation),
+ is_function(Predicate, 2) ->
+
+ Name = list_to_atom(
+ "theta_join_" ++ atom_to_list(LeftRelation#relation.name) ++ "_" ++
+ atom_to_list(RightRelation#relation.name) ++ "_" ++
+ integer_to_list(erlang:unique_integer([positive]))
+ ),
+
+ MergedSchema = maps:merge(LeftRelation#relation.schema, RightRelation#relation.schema),
+
+ LeftGen = LeftRelation#relation.generator,
+ RightGen = RightRelation#relation.generator,
+ GeneratorFun = fun() ->
+ LeftIter = LeftGen(),
+ RightIter = RightGen(),
+ operations:theta_join_iterator(LeftIter, RightIter, Predicate)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = MergedSchema,
+ constraints = #{},
+ cardinality = unknown,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = {join, LeftRelation#relation.provenance, RightRelation#relation.provenance},
+ lineage = {theta_join, Predicate, LeftRelation#relation.lineage, RightRelation#relation.lineage}
+ }.
+
+%% @doc Rename operator (ρ): Returns ephemeral relation with renamed attributes.
+%%
+%% Creates an ephemeral relation with attributes renamed according to the mapping.
+%% The generator closure captures the source relation and rename mappings.
+%%
+%% @param SourceRelation Source #relation{} record
+%% @param RenameMappings Map of old_name => new_name (atoms or strings)
+%% @returns Ephemeral #relation{} with generator
+-spec rename(#relation{}, #{atom() => atom()}) -> #relation{}.
+rename(SourceRelation, RenameMappings) when is_record(SourceRelation, relation), is_map(RenameMappings) ->
+ Name = generate_ephemeral_name(),
+
+ %% Compute renamed schema
+ RenamedSchema = maps:fold(
+ fun(OldAttr, NewAttr, SchemaAcc) ->
+ case maps:get(OldAttr, SchemaAcc, undefined) of
+ undefined -> SchemaAcc;
+ Type ->
+ SchemaAcc1 = maps:remove(OldAttr, SchemaAcc),
+ maps:put(NewAttr, Type, SchemaAcc1)
+ end
+ end,
+ SourceRelation#relation.schema,
+ RenameMappings
+ ),
+
+ SourceGen = SourceRelation#relation.generator,
+ GeneratorFun = fun() ->
+ SourceIter = SourceGen(),
+ rename_iterator(SourceIter, RenameMappings)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = RenamedSchema,
+ constraints = SourceRelation#relation.constraints,
+ cardinality = SourceRelation#relation.cardinality,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = SourceRelation#relation.provenance,
+ lineage = {rename, RenameMappings, SourceRelation#relation.lineage}
+ }.
+
+%% @doc Sort operator: Returns ephemeral relation with sorted tuples.
+%%
+%% Creates an ephemeral relation with tuples sorted by the comparator.
+%% WARNING: This is a blocking operation - it materializes all tuples.
+%%
+%% @param SourceRelation Source #relation{} record
+%% @param Comparator Function that compares two tuples
+%% @returns Ephemeral #relation{} with generator
+-spec sort(#relation{}, fun((map(), map()) -> boolean())) -> #relation{}.
+sort(SourceRelation, Comparator) when is_record(SourceRelation, relation), is_function(Comparator, 2) ->
+ Name = generate_ephemeral_name(),
+
+ SourceGen = SourceRelation#relation.generator,
+ GeneratorFun = fun() ->
+ SourceIter = SourceGen(),
+ operations:sort_iterator(SourceIter, Comparator)
+ end,
+
+ #relation{
+ hash = <<>>,
+ name = Name,
+ tree = undefined,
+ schema = SourceRelation#relation.schema,
+ constraints = SourceRelation#relation.constraints,
+ cardinality = SourceRelation#relation.cardinality,
+ generator = GeneratorFun,
+ membership_criteria = #{},
+ provenance = SourceRelation#relation.provenance,
+ lineage = {sort, Comparator, SourceRelation#relation.lineage}
+ }.
+
+%%% Helper Functions
+
+%% @private
+%% @doc Rename iterator - applies rename mapping to each tuple
+rename_iterator(SourceIter, RenameMappings) ->
+ spawn(fun() -> rename_loop(SourceIter, RenameMappings) end).
+
+rename_loop(SourceIter, RenameMappings) ->
+ receive
+ {next, Caller} ->
+ case operations:next_tuple(SourceIter) of
+ done ->
+ Caller ! done;
+ {ok, Tuple} ->
+ RenamedTuple = maps:fold(
+ fun(OldAttr, NewAttr, TupleAcc) ->
+ case maps:get(OldAttr, TupleAcc, undefined) of
+ undefined -> TupleAcc;
+ Value ->
+ TupleAcc1 = maps:remove(OldAttr, TupleAcc),
+ maps:put(NewAttr, Value, TupleAcc1)
+ end
+ end,
+ Tuple,
+ RenameMappings
+ ),
+ Caller ! {tuple, RenamedTuple},
+ rename_loop(SourceIter, RenameMappings)
+ end;
+ {close, Caller} ->
+ operations:close_iterator(SourceIter),
+ Caller ! ok
+ end.
diff --git a/src/repl.erl b/src/repl.erl
index c61ebfe..599d721 100644
--- a/src/repl.erl
+++ b/src/repl.erl
@@ -21,30 +21,27 @@
%%%
%%% == Query Syntax Examples ==
%%% <pre>
-%%% % Scan relation
-%%% {scan, employees}
+%%% % Base relation (just use the name)
+%%% employees
%%%
%%% % Filter
-%%% {select, {scan, employees}, fun(T) -&gt; maps:get(age, T) &gt; 30 end}
+%%% {select, employees, fun(T) -&gt; maps:get(age, T) &gt; 30 end}
%%%
%%% % Project
-%%% {project, {scan, employees}, [name, age]}
+%%% {project, employees, [name, age]}
%%%
%%% % Join
-%%% {join, {scan, employees}, {scan, departments}, dept_id}
+%%% {join, employees, departments, dept_id}
%%%
%%% % Rename
-%%% {rename, age, years, {scan, employees}}
+%%% {rename, age, years, employees}
%%%
%%% % Complex pipeline
%%% {take,
%%% {sort,
%%% {project,
%%% {select,
-%%% {join,
-%%% {scan, employees},
-%%% {scan, departments},
-%%% dept_id},
+%%% {join, employees, departments, dept_id},
%%% fun(T) -&gt; maps:get(age, T) &gt; 30 end},
%%% [name, dept_name, age]},
%%% fun(A, B) -&gt; maps:get(age, A) =&lt; maps:get(age, B) end},
@@ -79,7 +76,7 @@
%% @returns ok
-spec start() -> ok.
start() ->
- operations:setup(),
+ main:setup(),
io:format("~n~s~n", [banner()]),
io:format("Domino REPL started. Type repl:help() for usage.~n~n"),
ok.
@@ -250,10 +247,10 @@ help() ->
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(" repl:q(DB, employees). %% Run a query~n~n"),
io:format("Query Operators (all lazy by default):~n"),
- io:format(" {scan, Name} - Scan relation~n"),
+ io:format(" RelationName - Base 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"),
@@ -281,39 +278,41 @@ help() ->
io:format(" repl:modes() - List all available modes~n~n"),
io:format("Examples:~n"),
+ io:format(" %% Query all employees~n"),
+ io:format(" repl:q(DB, employees).~n~n"),
+
io:format(" %% Filter employees over 30~n"),
- io:format(" repl:q(DB, {select, {scan, employees}, ~n"),
+ io:format(" repl:q(DB, {select, 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(" repl:q(DB, {project, 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(" repl:q(DB, {join, employees, 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(" repl:q(DB, {rename, age, years, 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(" repl:q(DB, {materialize, 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(" repl:q(DB, {select, {materialize, 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(" repl:q(DB, employees, tree).~n"),
+ io:format(" repl:q(DB, employees, nested_table).~n"),
+ io:format(" repl:q(DB, employees, outline).~n~n"),
io:format(" %% Query without printing, then show results~n"),
- io:format(" Results = repl:query(DB, {scan, employees}).~n"),
+ io:format(" Results = repl:query(DB, 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(" repl:explain({select, employees, ~n"),
io:format(" fun(T) -> maps:get(age, T) > 30 end}).~n~n"),
ok.
diff --git a/src/xml_server.erl b/src/xml_server.erl
index 4eef4c9..eaa08f9 100644
--- a/src/xml_server.erl
+++ b/src/xml_server.erl
@@ -33,7 +33,7 @@
%%% xml_server:start(8080).
%%%
%%% % Client connects and sends:
-%%% % QUERY {scan, employees}\n
+%%% % QUERY employees\n
%%% % NEXT &lt;session-id&gt; 10\n
%%% % NEXT &lt;session-id&gt; 10\n
%%% % CLOSE &lt;session-id&gt;\n
@@ -209,9 +209,12 @@ handle_query(QueryStr, DB, Sessions) ->
{ok, Exprs} = erl_parse:parse_exprs(Tokens),
{value, QueryPlan, _} = erl_eval:exprs(Exprs, erl_eval:new_bindings()),
- % Prepare and execute query to get iterator
+ % Prepare and execute query to get ephemeral relation
PreparedPlan = query_planner:prepare(QueryPlan),
- Iterator = query_planner:execute(DB, PreparedPlan),
+ EphemeralRelation = query_planner:execute(DB, PreparedPlan),
+
+ % Create actual iterator from ephemeral relation's generator
+ Iterator = (EphemeralRelation#relation.generator)(),
% Generate session ID
SessionId = generate_session_id(),
diff --git a/src/xml_server_app.erl b/src/xml_server_app.erl
index d2e1c18..b7b9faa 100644
--- a/src/xml_server_app.erl
+++ b/src/xml_server_app.erl
@@ -12,7 +12,7 @@ start() ->
%% @doc Start XML server on specified port.
start(Port) ->
% Initialize Domino
- operations:setup(),
+ main:setup(),
io:format("~nDomino initialized~n"),
% Create example database
diff --git a/test/function_relations_test.erl b/test/function_relations_test.erl
deleted file mode 100644
index a6ab2d2..0000000
--- a/test/function_relations_test.erl
+++ /dev/null
@@ -1,347 +0,0 @@
--module(function_relations_test).
--include_lib("eunit/include/eunit.hrl").
--include("operations.hrl").
-
-%%% Function Relations Test Suite
-%%%
-%%% Testing infinite relations that represent arithmetic operations.
-%%% These work like functions but backwards too (Prolog-style)
-%%%
-%%% - Plus: {(a, b, sum) | sum = a + b }
-%%% - Times: {(a, b, product) | product = a * b}
-%%% - Minus: {(a, b, difference) | difference = a - b}
-%%% - Divide: {(a, b, quotient, remainder) | a = b*quotient + remainder}
-
-function_relations_test_() ->
- {foreach,
- fun setup/0,
- fun cleanup/1,
- [
- fun test_plus_both_operands_constrained/1,
- fun test_plus_a_and_sum_constrained/1,
- fun test_plus_b_and_sum_constrained/1,
- fun test_plus_only_sum_constrained/1,
- fun test_plus_sum_range_constrained/1,
- fun test_plus_insufficient_constraints/1,
- fun test_times_both_operands_constrained/1,
- fun test_times_insufficient_constraints/1,
- fun test_minus_both_operands_constrained/1,
- fun test_divide_both_operands_constrained/1,
- fun test_divide_avoids_zero/1,
- fun test_plus_join_with_finite/1,
- fun test_materialized_plus_relation/1
- ]}.
-
-%%% Setup and Cleanup
-
-setup() ->
- operations:setup(),
- DB = operations:create_database(test_db),
-
- % Integers and naturals are now built-in to every database
- % Just create the function relations using the new format
-
- % Create function relations - note how their schemas reference the domain relations
- {DB1, _Plus} = operations:create_immutable_relation(DB, #immutable_relation_spec{
- name = plus,
- schema = #{a => integer, b => integer, sum => integer},
- generator = {generators, plus},
- membership_criteria = #{},
- cardinality = aleph_zero
- }),
-
- {DB2, _Times} = operations:create_immutable_relation(DB1, #immutable_relation_spec{
- name = times,
- schema = #{a => integer, b => integer, product => integer},
- generator = {generators, times},
- membership_criteria = #{},
- cardinality = aleph_zero
- }),
-
- {DB3, _Minus} = operations:create_immutable_relation(DB2, #immutable_relation_spec{
- name = minus,
- schema = #{a => integer, b => integer, difference => integer},
- generator = {generators, minus},
- membership_criteria = #{},
- cardinality = aleph_zero
- }),
-
- {DB4, _Divide} = operations:create_immutable_relation(DB3, #immutable_relation_spec{
- name = divide,
- schema = #{a => integer, b => integer, quotient => integer, remainder => natural},
- generator = {generators, divide},
- membership_criteria = #{},
- cardinality = aleph_zero
- }),
-
- DB4.
-
-cleanup(_DB) ->
- ok.
-
-%%% Helper Functions
-
-%% Strip metadata from tuples for comparison
-strip_meta(Tuple) when is_map(Tuple) ->
- maps:remove(meta, Tuple);
-strip_meta(Tuples) when is_list(Tuples) ->
- [strip_meta(T) || T <- Tuples].
-
-%%% Plus Relation Tests
-
-test_plus_both_operands_constrained(DB) ->
- % Both operands bounded: a ∈ [1,3], b ∈ [2,4]
- % TODO: eventually this should be a proper join like Integer[A], Integer[B], Plus[A,B,Sum]
- % but for now just pass constraints directly
- Iterator = operations:get_tuples_iterator(DB, plus, #{
- a => {range, 1, 3},
- b => {range, 2, 4}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % should get 9 tuples total (3 × 3 = 9)
- % like (1,2,3), (1,3,4), (1,4,5), (2,2,4), ..., (3,4,7)
- [
- ?_assertEqual(9, length(Results)),
- ?_assert(lists:member(#{a => 1, b => 2, sum => 3}, DataOnly)),
- ?_assert(lists:member(#{a => 2, b => 3, sum => 5}, DataOnly)),
- ?_assert(lists:member(#{a => 3, b => 4, sum => 7}, DataOnly)),
- ?_assert(lists:all(fun(#{a := A, b := B, sum := S}) ->
- S =:= A + B
- end, DataOnly))
- ].
-
-test_plus_a_and_sum_constrained(DB) ->
- % Test reversibility - given A and Sum, compute B
- % a ∈ [1,3], sum ∈ [5,7], so b = sum - a
- % This is like asking: Plus[A, ?, Sum]
- Iterator = operations:get_tuples_iterator(DB, plus, #{
- a => {range, 1, 3},
- sum => {range, 5, 7}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % Expected tuples: (1,4,5), (1,5,6), (1,6,7), (2,3,5), (2,4,6), (2,5,7), (3,2,5), (3,3,6), (3,4,7)
- % (filtered to only keep where b >= 0)
- [
- ?_assert(length(Results) > 0),
- ?_assert(lists:all(fun(#{a := A, b := B, sum := S}) ->
- S =:= A + B andalso B >= 0
- end, DataOnly)),
- ?_assert(lists:member(#{a => 1, b => 4, sum => 5}, DataOnly)),
- ?_assert(lists:member(#{a => 3, b => 4, sum => 7}, DataOnly))
- ].
-
-test_plus_b_and_sum_constrained(DB) ->
- % Another reversibility test - now given B and Sum, find A
- % b ∈ [2,4], sum ∈ [5,6] → a = sum - b
- % Like: Plus[?, B, Sum]
- Iterator = operations:get_tuples_iterator(DB, plus, #{
- b => {range, 2, 4},
- sum => {range, 5, 6}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- [
- ?_assert(length(Results) > 0),
- ?_assert(lists:all(fun(#{a := A, b := B, sum := S}) ->
- S =:= A + B andalso A >= 0
- end, DataOnly)),
- ?_assert(lists:member(#{a => 3, b => 2, sum => 5}, DataOnly)),
- ?_assert(lists:member(#{a => 2, b => 4, sum => 6}, DataOnly))
- ].
-
-test_plus_only_sum_constrained(DB) ->
- % Only constrain the sum - should give us all ways to add up to 5
- % Plus[?, ?, 5] generates: (0,5), (1,4), (2,3), (3,2), (4,1), (5,0)
- Iterator = operations:get_tuples_iterator(DB, plus, #{
- sum => {eq, 5}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- [
- ?_assertEqual(6, length(Results)),
- ?_assert(lists:member(#{a => 0, b => 5, sum => 5}, DataOnly)),
- ?_assert(lists:member(#{a => 2, b => 3, sum => 5}, DataOnly)),
- ?_assert(lists:member(#{a => 5, b => 0, sum => 5}, DataOnly)),
- ?_assert(lists:all(fun(#{a := A, b := B, sum := S}) ->
- S =:= A + B andalso S =:= 5
- end, DataOnly))
- ].
-
-test_plus_sum_range_constrained(DB) ->
- % sum in a range [2,3] instead of single value
- Iterator = operations:get_tuples_iterator(DB, plus, #{
- sum => {range, 2, 3}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % For sum=2: (0,2), (1,1), (2,0) → 3 pairs
- % For sum=3: (0,3), (1,2), (2,1), (3,0) → 4 pairs
- % Total = 7
- [
- ?_assertEqual(7, length(Results)),
- ?_assert(lists:all(fun(#{sum := S}) -> S >= 2 andalso S =< 3 end, DataOnly)),
- ?_assert(lists:member(#{a => 0, b => 2, sum => 2}, DataOnly)),
- ?_assert(lists:member(#{a => 3, b => 0, sum => 3}, DataOnly))
- ].
-
-test_plus_insufficient_constraints(DB) ->
- % Try querying with zero constraints - should fail since we'd get infinite results
- Iterator = operations:get_tuples_iterator(DB, plus, #{}),
- Result = operations:next_tuple(Iterator),
-
- [
- ?_assertMatch({error, {unbounded_plus, _}}, Result)
- ].
-
-%%% Times (multiplication) Tests
-
-test_times_both_operands_constrained(DB) ->
- % Basic multiplication with both inputs bounded
- Iterator = operations:get_tuples_iterator(DB, times, #{
- a => {range, 2, 4},
- b => {range, 3, 5}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % 3×3 = 9 tuples
- [
- ?_assertEqual(9, length(Results)),
- ?_assert(lists:member(#{a => 2, b => 3, product => 6}, DataOnly)),
- ?_assert(lists:member(#{a => 3, b => 4, product => 12}, DataOnly)),
- ?_assert(lists:member(#{a => 4, b => 5, product => 20}, DataOnly)),
- ?_assert(lists:all(fun(#{a := A, b := B, product := P}) ->
- P =:= A * B
- end, DataOnly))
- ].
-
-test_times_insufficient_constraints(DB) ->
- % Times doesn't support full reversibility yet (would need factorization)
- % So constraining just one operand should error
- Iterator = operations:get_tuples_iterator(DB, times, #{a => {range, 1, 5}}),
- Result = operations:next_tuple(Iterator),
-
- [
- ?_assertMatch({error, {unbounded_times, _}}, Result)
- ].
-
-%%% Minus (subtraction) Tests
-
-test_minus_both_operands_constrained(DB) ->
- % subtraction with both operands bounded
- Iterator = operations:get_tuples_iterator(DB, minus, #{
- a => {range, 5, 7},
- b => {range, 2, 3}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % 3 × 2 = 6 tuples
- [
- ?_assertEqual(6, length(Results)),
- ?_assert(lists:member(#{a => 5, b => 2, difference => 3}, DataOnly)),
- ?_assert(lists:member(#{a => 7, b => 3, difference => 4}, DataOnly)),
- ?_assert(lists:all(fun(#{a := A, b := B, difference := D}) ->
- D =:= A - B
- end, DataOnly))
- ].
-
-%%% Divide (integer division) Tests
-
-test_divide_both_operands_constrained(DB) ->
- % Integer division with quotient and remainder
- Iterator = operations:get_tuples_iterator(DB, divide, #{
- a => {range, 10, 12},
- b => {range, 3, 4}
- }),
- Results = operations:collect_all(Iterator),
- DataOnly = strip_meta(Results),
-
- % 3 × 2 = 6 tuples
- [
- ?_assertEqual(6, length(Results)),
- ?_assert(lists:member(#{a => 10, b => 3, quotient => 3, remainder => 1}, DataOnly)),
- ?_assert(lists:member(#{a => 12, b => 4, quotient => 3, remainder => 0}, DataOnly)),
- ?_assert(lists:all(fun(#{a := A, b := B, quotient := Q, remainder := R}) ->
- A =:= B * Q + R andalso R >= 0 andalso R < abs(B)
- end, DataOnly))
- ].
-
-test_divide_avoids_zero(DB) ->
- % Make sure we don't try to divide by zero
- % even if the range includes 0
- Iterator = operations:get_tuples_iterator(DB, divide, #{
- a => {range, 5, 7},
- b => {range, 0, 2}
- }),
- Results = operations:collect_all(Iterator),
-
- % 3 × 2 = 6 (skips b=0, only uses b=1,2)
- [
- ?_assertEqual(6, length(Results)),
- ?_assert(lists:all(fun(#{b := B}) -> B =/= 0 end, Results))
- ].
-
-%%% Integration Tests
-
-test_plus_join_with_finite(DB) ->
- % Simulate joining an infinite relation with a finite one
- {DB1, _Employees} = operations:create_relation(DB, employees, #{
- name => string,
- age => integer
- }),
-
- {DB2, _} = operations:create_tuple(DB1, employees, #{name => "Alice", age => 30}),
- {DB3, _} = operations:create_tuple(DB2, employees, #{name => "Bob", age => 35}),
-
- EmployeeIter = operations:get_tuples_iterator(DB3, employees, #{}),
- Employees = operations:collect_all(EmployeeIter),
-
- % For each employee's age, find all the ways to add up to it
- % (simulating: Employee JOIN Plus ON Plus.sum = Employee.age)
- Results = lists:flatmap(fun(#{age := Age}) ->
- PlusIter = operations:get_tuples_iterator(DB3, plus, #{
- a => {range, 0, 15},
- b => {range, 0, 15},
- sum => {eq, Age}
- }),
- Tuples = operations:collect_all(PlusIter),
- [#{age => Age, plus_tuple => T} || T <- Tuples]
- end, Employees),
-
- % verify we got some results
- [
- ?_assert(length(Results) > 0),
- ?_assert(lists:any(fun(#{age := Age, plus_tuple := #{sum := S}}) ->
- Age =:= S
- end, Results))
- ].
-
-test_materialized_plus_relation(DB) ->
- % Take an infinite relation, constrain it, and materialize into a finite one
- PlusIter = operations:get_tuples_iterator(DB, plus, #{
- a => {range, 0, 2},
- b => {range, 0, 2}
- }),
-
- % Turn it into an actual stored relation
- {DB1, SmallPlus} = operations:materialize(DB, PlusIter, small_plus),
-
- % check that it worked
- ResultIter = operations:get_tuples_iterator(DB1, small_plus, #{}),
- Results = operations:collect_all(ResultIter),
-
- [
- ?_assertEqual({finite, 9}, SmallPlus#relation.cardinality),
- ?_assertEqual(9, length(Results)),
- ?_assert(lists:all(fun(#{a := A, b := B, sum := S}) ->
- S =:= A + B andalso A >= 0 andalso A =< 2 andalso B >= 0 andalso B =< 2
- end, Results))
- ].
diff --git a/test/infinite_relations_test.erl b/test/infinite_relations_test.erl
deleted file mode 100644
index 5f87acb..0000000
--- a/test/infinite_relations_test.erl
+++ /dev/null
@@ -1,128 +0,0 @@
--module(infinite_relations_test).
--include_lib("eunit/include/eunit.hrl").
--include("operations.hrl").
-
-%%% Test Suite for Infinite Relations
-
-infinite_relations_test_() ->
- {foreach,
- fun setup/0,
- fun cleanup/1,
- [
- fun test_create_naturals/1,
- fun test_naturals_with_constraints/1,
- fun test_integers_generator/1,
- fun test_rationals_generator/1,
- fun test_take_operator/1,
- fun test_finite_relation_cardinality/1
- ]}.
-
-%%% Setup and Cleanup
-
-setup() ->
- operations:setup(),
- operations:create_database(test_db).
-
-cleanup(_DB) ->
- ok.
-
-%%% Helper Functions
-
-%% Strip metadata from tuples for comparison
-strip_meta(Tuple) when is_map(Tuple) ->
- maps:remove(meta, Tuple);
-strip_meta(Tuples) when is_list(Tuples) ->
- [strip_meta(T) || T <- Tuples].
-
-%%% Tests
-
-test_create_naturals(DB) ->
- %% Naturals are now built-in to every database
- %% Just retrieve the relation to verify it exists
- {ok, NaturalsHash} = operations:get_relation_hash(DB, natural),
- [NaturalsRel] = mnesia:dirty_read(relation, NaturalsHash),
-
- [
- ?_assert(is_record(NaturalsRel, relation)),
- ?_assertEqual(natural, NaturalsRel#relation.name),
- ?_assertEqual(aleph_zero, NaturalsRel#relation.cardinality),
- ?_assertEqual({generators, natural}, NaturalsRel#relation.generator)
- ].
-
-test_naturals_with_constraints(DB) ->
- %% Naturals are built-in, just query them
- %% Query with range constraint [0, 9]
- Iterator = operations:get_tuples_iterator(DB, natural, #{value => {range, 0, 9}}),
- Tuples = operations:collect_all(Iterator),
- DataOnly = strip_meta(Tuples),
-
- %% Should get exactly 10 tuples: {0, 1, 2, ..., 9}
- [
- ?_assertEqual(10, length(Tuples)),
- ?_assert(lists:member(#{value => 0}, DataOnly)),
- ?_assert(lists:member(#{value => 5}, DataOnly)),
- ?_assert(lists:member(#{value => 9}, DataOnly))
- ].
-
-test_integers_generator(DB) ->
- %% Integers are built-in
- {ok, IntegersHash} = operations:get_relation_hash(DB, integer),
- [Integers] = mnesia:dirty_read(relation, IntegersHash),
-
- %% Query with range constraint [-5, 5]
- Iterator = operations:get_tuples_iterator(DB, integer, #{value => {range, -5, 5}}),
- Tuples = operations:collect_all(Iterator),
- DataOnly = strip_meta(Tuples),
-
- %% Should get 11 tuples: {-5, -4, ..., 0, ..., 4, 5}
- [
- ?_assertEqual(aleph_zero, Integers#relation.cardinality),
- ?_assertEqual(11, length(Tuples)),
- ?_assert(lists:member(#{value => 0}, DataOnly)),
- ?_assert(lists:member(#{value => -5}, DataOnly)),
- ?_assert(lists:member(#{value => 5}, DataOnly))
- ].
-
-test_rationals_generator(DB) ->
- %% Rationals are built-in
- {ok, RationalsHash} = operations:get_relation_hash(DB, rational),
- [Rationals] = mnesia:dirty_read(relation, RationalsHash),
-
- %% Take first 10 rationals using constraints
- Iterator = operations:get_tuples_iterator(DB, rational, #{}),
-
- %% Just get a few tuples to verify generator works
- {ok, First} = operations:next_tuple(Iterator),
- {ok, Second} = operations:next_tuple(Iterator),
- operations:close_iterator(Iterator),
-
- [
- ?_assertEqual(aleph_zero, Rationals#relation.cardinality),
- ?_assert(is_map(First)),
- ?_assert(maps:is_key(numerator, First)),
- ?_assert(maps:is_key(denominator, First)),
- ?_assert(is_map(Second))
- ].
-
-test_take_operator(DB) ->
- %% Naturals are built-in, just use them
- %% Take 100 naturals
- {_DB2, Naturals100} = operations:take(DB, natural, 100),
-
- %% Verify result is finite
- [
- ?_assertEqual({finite, 100}, Naturals100#relation.cardinality),
- ?_assertEqual({take, natural, 100}, Naturals100#relation.generator)
- ].
-
-test_finite_relation_cardinality(DB) ->
- %% Create finite relation
- {DB1, _} = operations:create_relation(DB, users, #{name => string, age => integer}),
-
- %% Add tuple
- {_DB2, UpdatedRel} = operations:create_tuple(DB1, users, #{name => "Alice", age => 30}),
-
- %% Cardinality should be 1
- [
- ?_assertEqual({finite, 1}, UpdatedRel#relation.cardinality)
- ].
diff --git a/test/join_test.erl b/test/join_test.erl
deleted file mode 100644
index de62127..0000000
--- a/test/join_test.erl
+++ /dev/null
@@ -1,273 +0,0 @@
--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) ->
- % Integers are built-in, no need to create them
-
- % Get small set of integers
- IntIter = operations:get_tuples_iterator(DB, integer, #{value => {range, 1, 5}}),
-
- % Get employees
- EmpIter = operations:get_tuples_iterator(DB, 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))
- ].
diff --git a/test/operations_test.erl b/test/operations_test.erl
deleted file mode 100644
index 31bde43..0000000
--- a/test/operations_test.erl
+++ /dev/null
@@ -1,366 +0,0 @@
--module(operations_test).
--include("operations.hrl").
--include_lib("eunit/include/eunit.hrl").
-
-%% Test fixtures and setup
-setup_test_env() ->
- operations:setup(),
- ok.
-
-cleanup_test_env() ->
- mnesia:stop(),
- ok.
-
-%% Fixture: Create a sample database with a users relation
-sample_database() ->
- DB = operations:create_database(test_db),
- {DB1, _Relation} = operations:create_relation(DB, users, #{
- name => string,
- age => integer
- }),
- DB1.
-
-%% Fixture: Add sample tuples to a database
-sample_database_with_tuples() ->
- DB = sample_database(),
- {DB1, _} = operations:create_tuple(DB, users, #{name => "Alice", age => 30}),
- {DB2, _} = operations:create_tuple(DB1, users, #{name => "Bob", age => 25}),
- DB2.
-
-%%====================================================================
-%% Database Creation Tests
-%%====================================================================
-
-database_creation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = operations:create_database(my_db),
- ?assertEqual(my_db, DB#database_state.name),
- % Database now comes with built-in infinite relations
- Relations = operations:get_relations(DB),
- ?assert(lists:member(natural, Relations)),
- ?assert(lists:member(integer, Relations)),
- ?assert(lists:member(rational, Relations)),
- ?assertNotEqual(undefined, DB#database_state.timestamp)
- end)]}.
-
-%%====================================================================
-%% Relation Creation Tests
-%%====================================================================
-
-relation_creation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = operations:create_database(test_db),
- Schema = #{name => string, email => string},
- {DB1, Relation} = operations:create_relation(DB, users, Schema),
-
- % Verify relation was created
- ?assertEqual(users, Relation#relation.name),
- ?assertEqual(Schema, Relation#relation.schema),
-
- % Verify database was updated (includes built-in relations)
- Relations = operations:get_relations(DB1),
- ?assert(lists:member(users, Relations)),
- ?assert(lists:member(natural, Relations)),
- ?assert(lists:member(integer, Relations)),
- ?assert(lists:member(rational, Relations)),
- {ok, RelationHash} = operations:get_relation_hash(DB1, users),
- ?assertEqual(Relation#relation.hash, RelationHash)
- end)]}.
-
-%%====================================================================
-%% Tuple Operations Tests
-%%====================================================================
-
-tuple_creation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database(),
- InitialHash = DB#database_state.hash,
-
- % Add a tuple
- {DB1, _} = operations:create_tuple(DB, users, #{name => "Alice", age => 30}),
-
- % Verify database hash changed
- ?assertNotEqual(InitialHash, DB1#database_state.hash),
-
- % Verify tuple can be retrieved
- Iterator = operations:get_tuples_iterator(DB1, users),
- Tuples = operations:collect_all(Iterator),
- ?assertEqual(1, length(Tuples)),
- [Tuple] = Tuples,
- ?assertEqual("Alice", maps:get(name, Tuple)),
- ?assertEqual(30, maps:get(age, Tuple))
- end)]}.
-
-tuple_multiple_insertions_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database(),
-
- % Add multiple tuples
- {DB1, _} = operations:create_tuple(DB, users, #{name => "Alice", age => 30}),
- {DB2, _} = operations:create_tuple(DB1, users, #{name => "Bob", age => 25}),
- {DB3, _} = operations:create_tuple(DB2, users, #{name => "Charlie", age => 35}),
-
- % Verify all tuples can be retrieved
- Iterator = operations:get_tuples_iterator(DB3, users),
- Tuples = operations:collect_all(Iterator),
- ?assertEqual(3, length(Tuples)),
-
- % Verify hashes changed with each insertion
- ?assertNotEqual(DB#database_state.hash, DB1#database_state.hash),
- ?assertNotEqual(DB1#database_state.hash, DB2#database_state.hash),
- ?assertNotEqual(DB2#database_state.hash, DB3#database_state.hash)
- end)]}.
-
-%%====================================================================
-%% Hash Comparison Tests (Time-based)
-%%====================================================================
-
-database_state_hash_comparison_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database(),
- Hash0 = DB#database_state.hash,
-
- % State 1: Add first tuple
- {DB1, _} = operations:create_tuple(DB, users, #{name => "Alice", age => 30}),
- Hash1 = DB1#database_state.hash,
-
- % State 2: Add second tuple
- {DB2, _} = operations:create_tuple(DB1, users, #{name => "Bob", age => 25}),
- Hash2 = DB2#database_state.hash,
-
- % State 3: Add third tuple
- {DB3, _} = operations:create_tuple(DB2, users, #{name => "Charlie", age => 35}),
- Hash3 = DB3#database_state.hash,
-
- % Verify all hashes are unique
- ?assertNotEqual(Hash0, Hash1),
- ?assertNotEqual(Hash1, Hash2),
- ?assertNotEqual(Hash2, Hash3),
- ?assertNotEqual(Hash0, Hash2),
- ?assertNotEqual(Hash0, Hash3),
- ?assertNotEqual(Hash1, Hash3)
- end)]}.
-
-relation_hash_comparison_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database(),
- {ok, RelationHash0} = operations:get_relation_hash(DB, users),
-
- % Add tuple and get new relation hash
- {DB1, Relation1} = operations:create_tuple(DB, users, #{name => "Alice", age => 30}),
- RelationHash1 = Relation1#relation.hash,
-
- % Add another tuple
- {DB2, Relation2} = operations:create_tuple(DB1, users, #{name => "Bob", age => 25}),
- RelationHash2 = Relation2#relation.hash,
-
- % Verify relation hashes changed over time
- ?assertNotEqual(RelationHash0, RelationHash1),
- ?assertNotEqual(RelationHash1, RelationHash2),
- ?assertNotEqual(RelationHash0, RelationHash2),
-
- % Verify we can still get relation hash from database
- {ok, CurrentHash} = operations:get_relation_hash(DB2, users),
- ?assertEqual(RelationHash2, CurrentHash)
- end)]}.
-
-%%====================================================================
-%% Tuple Retraction Tests
-%%====================================================================
-
-retract_tuple_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database_with_tuples(),
- {ok, RelationHash} = operations:get_relation_hash(DB, users),
-
- % Get tuple hashes
- TupleHashes = operations:hashes_from_tuple(RelationHash),
- ?assertEqual(2, length(TupleHashes)),
-
- % Retract one tuple
- [TupleHash | _] = TupleHashes,
- {atomic, {DB1, _}} = operations:retract_tuple(DB, RelationHash, TupleHash),
-
- % Verify tuple was removed
- {ok, NewRelationHash} = operations:get_relation_hash(DB1, users),
- NewTupleHashes = operations:hashes_from_tuple(NewRelationHash),
- ?assertEqual(1, length(NewTupleHashes)),
-
- % Verify database hash changed
- ?assertNotEqual(DB#database_state.hash, DB1#database_state.hash)
- end)]}.
-
-%%====================================================================
-%% Relation Operations Tests
-%%====================================================================
-
-clear_relation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database_with_tuples(),
- {ok, RelationHash} = operations:get_relation_hash(DB, users),
-
- % Verify tuples exist
- TupleHashes = operations:hashes_from_tuple(RelationHash),
- ?assertEqual(2, length(TupleHashes)),
-
- % Clear relation
- {atomic, {DB1, ClearedRelation}} = operations:clear_relation(DB, RelationHash),
-
- % Verify relation is empty
- ?assertEqual(undefined, ClearedRelation#relation.tree),
- NewTupleHashes = operations:hashes_from_tuple(ClearedRelation#relation.hash),
- ?assertEqual([], NewTupleHashes),
-
- % Verify database hash changed
- ?assertNotEqual(DB#database_state.hash, DB1#database_state.hash)
- end)]}.
-
-retract_relation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database_with_tuples(),
-
- % Verify relation exists (along with built-in relations)
- Relations = operations:get_relations(DB),
- ?assert(lists:member(users, Relations)),
-
- % Retract relation
- {atomic, DB1} = operations:retract_relation(DB, users),
-
- % Verify users relation was removed (built-in relations remain)
- Relations1 = operations:get_relations(DB1),
- ?assertNot(lists:member(users, Relations1)),
- ?assert(lists:member(natural, Relations1)),
- ?assert(lists:member(integer, Relations1)),
- ?assert(lists:member(rational, Relations1)),
- ?assertEqual({error, not_found}, operations:get_relation_hash(DB1, users)),
-
- % Verify database hash changed
- ?assertNotEqual(DB#database_state.hash, DB1#database_state.hash)
- end)]}.
-
-%%====================================================================
-%% Iterator Tests
-%%====================================================================
-
-iterator_basic_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database_with_tuples(),
-
- % Get iterator and retrieve tuples one by one
- Iterator = operations:get_tuples_iterator(DB, users),
-
- % Get first tuple
- {ok, Tuple1} = operations:next_tuple(Iterator),
- ?assert(is_map(Tuple1)),
- ?assert(maps:is_key(name, Tuple1)),
- ?assert(maps:is_key(age, Tuple1)),
-
- % Get second tuple
- {ok, Tuple2} = operations:next_tuple(Iterator),
- ?assert(is_map(Tuple2)),
-
- % No more tuples
- ?assertEqual(done, operations:next_tuple(Iterator)),
-
- operations:close_iterator(Iterator)
- end)]}.
-
-iterator_collect_all_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database_with_tuples(),
-
- Iterator = operations:get_tuples_iterator(DB, users),
- Tuples = operations:collect_all(Iterator),
-
- ?assertEqual(2, length(Tuples)),
-
- % Verify both tuples are present
- Names = [maps:get(name, T) || T <- Tuples],
- ?assert(lists:member("Alice", Names)),
- ?assert(lists:member("Bob", Names))
- end)]}.
-
-iterator_empty_relation_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- DB = sample_database(),
-
- Iterator = operations:get_tuples_iterator(DB, users),
- Tuples = operations:collect_all(Iterator),
-
- ?assertEqual([], Tuples)
- end)]}.
-
-%%====================================================================
-%% Hash Consistency Tests
-%%====================================================================
-
-hash_determinism_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- % Same value should produce same hash
- Hash1 = operations:hash(<<"test">>),
- Hash2 = operations:hash(<<"test">>),
- ?assertEqual(Hash1, Hash2),
-
- % Different values should produce different hashes
- Hash3 = operations:hash(<<"different">>),
- ?assertNotEqual(Hash1, Hash3)
- end)]}.
-
-database_state_consistency_test_() ->
- {setup,
- fun setup_test_env/0,
- fun(_) -> cleanup_test_env() end,
- [?_test(begin
- % Create two identical databases with same operations
- DB1 = operations:create_database(db1),
- {DB1_1, _} = operations:create_relation(DB1, users, #{name => string}),
- {DB1_2, _} = operations:create_tuple(DB1_1, users, #{name => "Alice"}),
-
- DB2 = operations:create_database(db2),
- {DB2_1, _} = operations:create_relation(DB2, users, #{name => string}),
- {DB2_2, _} = operations:create_tuple(DB2_1, users, #{name => "Alice"}),
-
- % Relations should have the same hash (same schema and tuples)
- {ok, RelHash1} = operations:get_relation_hash(DB1_2, users),
- {ok, RelHash2} = operations:get_relation_hash(DB2_2, users),
- ?assertEqual(RelHash1, RelHash2)
- end)]}.
diff --git a/test/provenance_test.erl b/test/provenance_test.erl
deleted file mode 100644
index c98e372..0000000
--- a/test/provenance_test.erl
+++ /dev/null
@@ -1,420 +0,0 @@
--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))
- ].
diff --git a/test/query_pipeline_test.erl b/test/query_pipeline_test.erl
deleted file mode 100644
index a76c2ff..0000000
--- a/test/query_pipeline_test.erl
+++ /dev/null
@@ -1,183 +0,0 @@
--module(query_pipeline_test).
--include_lib("eunit/include/eunit.hrl").
--include("operations.hrl").
-
-%%% Test Suite for Query Pipeline (Volcano Iterator Model)
-
-query_pipeline_test_() ->
- {foreach,
- fun setup/0,
- fun cleanup/1,
- [
- fun test_select_iterator/1,
- fun test_project_iterator/1,
- fun test_take_iterator/1,
- fun test_sort_iterator/1,
- fun test_composed_pipeline/1,
- fun test_materialize_finite/1,
- fun test_materialize_infinite/1,
- fun test_complete_query_pipeline/1
- ]}.
-
-%%% Setup and Cleanup
-
-setup() ->
- operations:setup(),
- DB = operations:create_database(test_db),
-
- %% Create employees relation with test data
- {DB1, _} = operations:create_relation(DB, employees, #{
- name => string,
- age => integer,
- department => string
- }),
-
- {DB2, _} = operations:create_tuple(DB1, employees, #{name => "Alice", age => 35, department => "Engineering"}),
- {DB3, _} = operations:create_tuple(DB2, employees, #{name => "Bob", age => 28, department => "Sales"}),
- {DB4, _} = operations:create_tuple(DB3, employees, #{name => "Carol", age => 42, department => "Engineering"}),
- {DB5, _} = operations:create_tuple(DB4, employees, #{name => "Dave", age => 31, department => "Marketing"}),
- {DB6, _} = operations:create_tuple(DB5, employees, #{name => "Eve", age => 29, department => "Engineering"}),
-
- DB6.
-
-cleanup(_DB) ->
- ok.
-
-%%% Tests
-
-test_select_iterator(DB) ->
- %% Filter employees age > 30
- BaseIter = operations:get_tuples_iterator(DB, employees, #{}),
- FilteredIter = operations:select_iterator(BaseIter,
- fun(E) -> maps:get(age, E) > 30 end),
- Results = operations:collect_all(FilteredIter),
-
- %% Should get 3 employees: Alice (35), Carol (42), Dave (31)
- [
- ?_assertEqual(3, length(Results)),
- ?_assert(lists:all(fun(E) -> maps:get(age, E) > 30 end, Results))
- ].
-
-test_project_iterator(DB) ->
- %% Project to [name, age] only
- BaseIter = operations:get_tuples_iterator(DB, employees, #{}),
- ProjectedIter = operations:project_iterator(BaseIter, [name, age]),
- Results = operations:collect_all(ProjectedIter),
-
- %% Should have only name and age fields
- [
- ?_assertEqual(5, length(Results)),
- ?_assert(lists:all(fun(E) ->
- maps:is_key(name, E) andalso
- maps:is_key(age, E) andalso
- not maps:is_key(department, E)
- end, Results))
- ].
-
-test_take_iterator(DB) ->
- %% Take first 3 employees
- BaseIter = operations:get_tuples_iterator(DB, employees, #{}),
- LimitedIter = operations:take_iterator(BaseIter, 3),
- Results = operations:collect_all(LimitedIter),
-
- [
- ?_assertEqual(3, length(Results))
- ].
-
-test_sort_iterator(DB) ->
- %% Sort by age ascending
- BaseIter = operations:get_tuples_iterator(DB, employees, #{}),
- SortedIter = operations:sort_iterator(BaseIter,
- fun(A, B) -> maps:get(age, A) =< maps:get(age, B) end),
- Results = operations:collect_all(SortedIter),
-
- Ages = [maps:get(age, E) || E <- Results],
-
- [
- ?_assertEqual(5, length(Results)),
- ?_assertEqual([28, 29, 31, 35, 42], Ages)
- ].
-
-test_composed_pipeline(DB) ->
- %% Complex pipeline: filter age > 30, sort by age, take 2, project [name]
- Iter1 = operations:get_tuples_iterator(DB, employees, #{}),
- Iter2 = operations:select_iterator(Iter1, fun(E) -> maps:get(age, E) > 30 end),
- Iter3 = operations:sort_iterator(Iter2, fun(A, B) -> maps:get(age, A) =< maps:get(age, B) end),
- Iter4 = operations:take_iterator(Iter3, 2),
- Pipeline = operations:project_iterator(Iter4, [name]),
-
- Results = operations:collect_all(Pipeline),
-
- %% Should get 2 oldest employees over 30: Dave (31), Alice (35)
- Names = [maps:get(name, R) || R <- Results],
-
- [
- ?_assertEqual(2, length(Results)),
- ?_assertEqual(["Dave", "Alice"], Names),
- ?_assert(lists:all(fun(R) -> not maps:is_key(age, R) end, Results))
- ].
-
-test_materialize_finite(DB) ->
- %% Build pipeline and materialize
- Iter1 = operations:get_tuples_iterator(DB, employees, #{}),
- Iter2 = operations:select_iterator(Iter1, fun(E) ->
- maps:get(department, E) =:= "Engineering"
- end),
- Pipeline = operations:project_iterator(Iter2, [name, age]),
-
- {DB1, EngineersRel} = operations:materialize(DB, Pipeline, engineers),
-
- %% Verify new relation
- [
- ?_assert(is_record(EngineersRel, relation)),
- ?_assertEqual(engineers, EngineersRel#relation.name),
- ?_assertEqual({finite, 3}, EngineersRel#relation.cardinality),
-
- %% Can query the materialized relation
- ?_assertMatch([_,_,_],
- operations:collect_all(operations:get_tuples_iterator(DB1, engineers, #{})))
- ].
-
-test_materialize_infinite(DB) ->
- %% Naturals are built-in, no need to create them
-
- %% Take 100 and materialize
- Pipeline = operations:get_tuples_iterator(DB, natural, #{value => {range, 0, 99}}),
- {_DB2, Naturals100} = operations:materialize(DB, Pipeline, naturals_100),
-
- [
- ?_assertEqual({finite, 100}, Naturals100#relation.cardinality),
- ?_assertEqual(naturals_100, Naturals100#relation.name)
- ].
-
-test_complete_query_pipeline(DB) ->
- %% Realistic query:
- %% SELECT name, age FROM employees
- %% WHERE age > 28
- %% ORDER BY age DESC
- %% LIMIT 3
-
- Iter1 = operations:get_tuples_iterator(DB, employees, #{}),
- Iter2 = operations:select_iterator(Iter1, fun(E) -> maps:get(age, E) > 28 end),
- Iter3 = operations:sort_iterator(Iter2, fun(A, B) ->
- maps:get(age, A) >= maps:get(age, B) % DESC
- end),
- Iter4 = operations:take_iterator(Iter3, 3),
- Pipeline = operations:project_iterator(Iter4, [name, age]),
-
- {DB1, TopEmployees} = operations:materialize(DB, Pipeline, top_3_employees),
-
- %% Verify result - NOTE: Relations are unordered sets!
- %% After materialization, tuples come back in hash order, not sort order
- Results = operations:collect_all(operations:get_tuples_iterator(DB1, top_3_employees, #{})),
- Ages = lists:sort(fun(A, B) -> A >= B end, [maps:get(age, R) || R <- Results]),
-
- [
- ?_assertEqual(3, length(Results)),
- ?_assertEqual([42, 35, 31], Ages), % Verify we have the right ages (sorted DESC for check)
- ?_assert(lists:all(fun(R) ->
- Age = maps:get(age, R),
- lists:member(Age, [42, 35, 31])
- end, Results)),
- ?_assertEqual({finite, 3}, TopEmployees#relation.cardinality)
- ].