diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.erl | 60 | ||||
| -rw-r--r-- | src/operations.erl | 593 | ||||
| -rw-r--r-- | src/query_planner.erl | 356 | ||||
| -rw-r--r-- | src/relational_operators.erl | 366 | ||||
| -rw-r--r-- | src/repl.erl | 49 | ||||
| -rw-r--r-- | src/xml_server.erl | 9 | ||||
| -rw-r--r-- | src/xml_server_app.erl | 2 |
7 files changed, 894 insertions, 541 deletions
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) -> maps:get(age, T) > 30 end} +%%% {select, employees, fun(T) -> maps:get(age, T) > 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) -> maps:get(dept_id, L) =:= maps:get(id, R) end} %%% %%% % Sort -%%% {sort, {scan, employees}, fun(A, B) -> maps:get(age, A) =< maps:get(age, B) end} +%%% {sort, employees, fun(A, B) -> maps:get(age, A) =< 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) -> maps:get(age, T) > 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) -> maps:get(age, T) > 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) -> maps:get(age, T) > 30 end} +%%% {select, employees, fun(T) -> maps:get(age, T) > 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) -> maps:get(age, T) > 30 end}, %%% [name, dept_name, age]}, %%% fun(A, B) -> maps:get(age, A) =< 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 <session-id> 10\n %%% % NEXT <session-id> 10\n %%% % CLOSE <session-id>\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 |
