diff options
| author | Marcos Magueta <maguetamarcos@gmail.com> | 2025-12-31 21:07:26 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-31 21:07:26 -0300 |
| commit | c9b69ab3ae238da966bcba3d3e86f5c27a0df922 (patch) | |
| tree | 68bcc6731a783a923524b46a436dcffb1f582988 /src | |
| parent | ff5691ffb1a969e50e0bd5e4638be8e2bb342c4e (diff) | |
| download | Sakura-c9b69ab3ae238da966bcba3d3e86f5c27a0df922.tar.gz Sakura-c9b69ab3ae238da966bcba3d3e86f5c27a0df922.zip | |
1OPs, bound context and domain constraints (#46)
Diffstat (limited to 'src')
| -rw-r--r-- | src/constraint.erl | 1175 | ||||
| -rw-r--r-- | src/operations.erl | 47 | ||||
| -rw-r--r-- | src/relational_operators.erl | 164 |
3 files changed, 1353 insertions, 33 deletions
diff --git a/src/constraint.erl b/src/constraint.erl new file mode 100644 index 0000000..5ea7868 --- /dev/null +++ b/src/constraint.erl @@ -0,0 +1,1175 @@ +-module(constraint). + +-include("../include/operations.hrl"). + +-export([ + less_than/1, + less_than_or_equal/1, + greater_than/1, + greater_than_or_equal/1, + equal/1, + not_equal/1, + member/2, + member/3, + 'not'/2, + 'and'/2, + 'or'/2, + create_1op/3, + derive_1op/2, + declare_1opic/3, + validate_1opic/3, + resolve/3, + validate_tuple/2, + validate_tuple_against_schema/2, + validate_schema/1, + build_membership_criteria/1, + register_domain/1, + unregister_domain/1, + list_domains/0, + lt/1, % value < X + lte/1, % value <= X + gt/1, % value > X + gte/1, % value >= X + eq/1, % value = X + neq/1, % value != X + between/2, % Min <= value < Max + %% Constraint Propagation + empty_constraints/0, % Create empty #relation_constraints{} + merge_constraints/2, % Merge two constraint records (AND semantics) + filter_constraints/2, % Keep only constraints for given attributes + rename_constraint_attrs/2, % Update attribute names in constraints + infer_constraints_from_pred/2, % Extract constraints from predicate + add_attribute_constraint/3 % Add an attribute constraint to constraints +]). + +-type relational_constraint() :: + {member_of, relation_name(), binding()} % (x, y, ...) in R + | {'not', relational_constraint(), domain()} % NOT P within universe D + | {'and', [relational_constraint()]} % P1 AND P2 AND ... + | {'or', [relational_constraint()]} % P1 OR P2 OR ... + | {exists, atom(), relational_constraint()} % EXISTS x: P(x) + | {forall, atom(), domain(), relational_constraint()}. % FORALL x in D: P(x) + +-type relation_name() :: atom(). +-type domain() :: atom() | #domain{}. +-type binding() :: #{atom() => term() | {var, atom()}}. + +-export_type([relational_constraint/0, binding/0]). + +-spec less_than(atom()) -> #relation{}. +less_than(DomainName) -> + #relation{ + hash = undefined, % Computed lazily if needed + name = list_to_atom("less_than_" ++ atom_to_list(DomainName)), + tree = undefined, % Generator-based, not stored + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + cardinality = comparison_cardinality(DomainName), + generator = {comparison, '<', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L < R end + }, + provenance = undefined, + lineage = {base, list_to_atom("less_than_" ++ atom_to_list(DomainName))} + }. + +-spec less_than_or_equal(atom()) -> #relation{}. +less_than_or_equal(DomainName) -> + #relation{ + hash = undefined, + name = list_to_atom("less_than_or_equal_" ++ atom_to_list(DomainName)), + tree = undefined, + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + cardinality = comparison_cardinality(DomainName), + generator = {comparison, '=<', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L =< R end + }, + provenance = undefined, + lineage = {base, list_to_atom("less_than_or_equal_" ++ atom_to_list(DomainName))} + }. + +-spec greater_than(atom()) -> #relation{}. +greater_than(DomainName) -> + #relation{ + hash = undefined, + name = list_to_atom("greater_than_" ++ atom_to_list(DomainName)), + tree = undefined, + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + cardinality = comparison_cardinality(DomainName), + generator = {comparison, '>', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L > R end + }, + provenance = undefined, + lineage = {base, list_to_atom("greater_than_" ++ atom_to_list(DomainName))} + }. + +-spec greater_than_or_equal(atom()) -> #relation{}. +greater_than_or_equal(DomainName) -> + #relation{ + hash = undefined, + name = list_to_atom("greater_than_or_equal_" ++ atom_to_list(DomainName)), + tree = undefined, + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + cardinality = comparison_cardinality(DomainName), + generator = {comparison, '>=', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L >= R end + }, + provenance = undefined, + lineage = {base, list_to_atom("greater_than_or_equal_" ++ atom_to_list(DomainName))} + }. + +-spec equal(atom()) -> #relation{}. +equal(DomainName) -> + #relation{ + hash = undefined, + name = list_to_atom("equal_" ++ atom_to_list(DomainName)), + tree = undefined, + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + %% The diagonal has the same cardinality as the domain itself + cardinality = domain_cardinality(DomainName), + generator = {comparison, '==', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L == R end + }, + provenance = undefined, + lineage = {base, list_to_atom("equal_" ++ atom_to_list(DomainName))} + }. + +-spec not_equal(atom()) -> #relation{}. +not_equal(DomainName) -> + #relation{ + hash = undefined, + name = list_to_atom("not_equal_" ++ atom_to_list(DomainName)), + tree = undefined, + schema = #{left => DomainName, right => DomainName}, + constraints = #{}, + cardinality = comparison_cardinality(DomainName), + generator = {comparison, '/=', DomainName}, + membership_criteria = #{ + intension => fun(#{left := L, right := R}) -> L /= R end + }, + provenance = undefined, + lineage = {base, list_to_atom("not_equal_" ++ atom_to_list(DomainName))} + }. + +-spec member(map(), #domain{} | #relation{}) -> boolean(). +member(Tuple, #relation{membership_criteria = #{intension := Intension}}) -> + %% Generator-based relation with intension predicate (e.g., less_than) + Intension(Tuple); +member(_Tuple, #relation{tree = undefined, generator = Generator}) when Generator =/= undefined -> + %% Generator-based relation without explicit intension - would need enumeration + %% This is a fallback; ideally all generator relations have an intension + {error, {no_intension, requires_enumeration}}; +member(Tuple, #relation{} = Relation) -> + %% Stored relation - check actual membership in the tree + relation_contains(Tuple, Relation); +member(Tuple, #domain{membership_criteria = #{test := TestFun}}) -> + %% Domain (1OP) with a test function + TestFun(Tuple); +member(Tuple, #domain{membership_criteria = Criteria}) when is_map(Criteria) -> + %% Domain with attribute-level criteria + validate_tuple_against_criteria(Tuple, Criteria). + +-spec member(binding(), #domain{} | #relation{}, map()) -> + {true, map()} | false. +member(Binding, Relation, Substitution) -> + BoundTuple = apply_substitution(Binding, Substitution), + %% We also gotta look at th remaining variables + case find_variables(BoundTuple) of + [] -> + %% For the fully bound, apply the direct membership test + case member(BoundTuple, Relation) of + true -> {true, Substitution}; + false -> false + end; + Variables -> + %% In the scenario we have actual variables, we need to search for satisfying values + %% which requires enumeration through the generator + find_satisfying_substitution(BoundTuple, Variables, Relation, Substitution) + end. + +-spec 'not'(#relation{}, #relation{}) -> #relation{}. +'not'(R, Universe) -> + RName = get_name(R), + UniverseName = get_name(Universe), + ResultName = list_to_atom("not_" ++ atom_to_list(RName) ++ + "_in_" ++ atom_to_list(UniverseName)), + #relation{ + hash = undefined, + name = ResultName, + tree = undefined, + schema = get_schema(R), + constraints = #{}, + cardinality = complement_cardinality(R, Universe), + generator = {complement, R, Universe}, + membership_criteria = #{ + intension => fun(Tuple) -> + member(Tuple, Universe) andalso not member(Tuple, R) + end + }, + provenance = undefined, + lineage = {complement, get_lineage(R), get_lineage(Universe)} + }. + +-spec 'and'(#relation{}, #relation{}) -> #relation{}. +'and'(R1, R2) -> + Schema1 = get_schema(R1), + Schema2 = get_schema(R2), + case schemas_compatible(Schema1, Schema2) of + false -> + error({incompatible_schemas, Schema1, Schema2}); + true -> + R1Name = get_name(R1), + R2Name = get_name(R2), + ResultName = list_to_atom(atom_to_list(R1Name) ++ + "_and_" ++ atom_to_list(R2Name)), + #relation{ + hash = undefined, + name = ResultName, + tree = undefined, + schema = merge_schemas(Schema1, Schema2), + constraints = #{}, + cardinality = intersection_cardinality(R1, R2), + generator = {intersection, R1, R2}, + membership_criteria = #{ + intension => fun(Tuple) -> + member(Tuple, R1) andalso member(Tuple, R2) + end + }, + provenance = undefined, + lineage = {intersection, get_lineage(R1), get_lineage(R2)} + } + end. + +%% @doc Union of two relations (logical OR). +%% +%% Returns a relation containing tuples that are in R1 OR R2 (or both). +%% Requires compatible schemas. +%% +%% @param R1 First relation +%% @param R2 Second relation +%% @returns A new relation representing the union +-spec 'or'(#relation{}, #relation{}) -> #relation{}. +'or'(R1, R2) -> + Schema1 = get_schema(R1), + Schema2 = get_schema(R2), + case schemas_compatible(Schema1, Schema2) of + false -> + error({incompatible_schemas, Schema1, Schema2}); + true -> + R1Name = get_name(R1), + R2Name = get_name(R2), + ResultName = list_to_atom(atom_to_list(R1Name) ++ + "_or_" ++ atom_to_list(R2Name)), + #relation{ + hash = undefined, + name = ResultName, + tree = undefined, + schema = merge_schemas(Schema1, Schema2), + constraints = #{}, + cardinality = union_cardinality(R1, R2), + generator = {union, R1, R2}, + membership_criteria = #{ + intension => fun(Tuple) -> + member(Tuple, R1) orelse member(Tuple, R2) + end + }, + provenance = undefined, + lineage = {union, get_lineage(R1), get_lineage(R2)} + } + end. + +%%% 1OP Constraint Operations (Attribute Constraints) + +%% @doc Create a constraint for a 1st Order Property (attribute constraint). +%% +%% A 1OP is a property (attribute) of an entity. This function creates a CONSTRAINT +%% for that 1OP which: +%% 1. Specifies which domain (or relation) the attribute draws values from +%% 2. Applies additional constraints beyond domain membership +%% +%% The validation process for a 1OP constraint: +%% 1. Get the attribute value from a tuple +%% 2. Check if value ∈ domain (using domain's membership criteria) +%% 3. Apply additional constraints specified here +-spec create_1op(atom(), atom(), [relational_constraint()]) -> #attribute_constraint{}. +create_1op(AttributeName, Domain, Constraints) -> + #attribute_constraint{ + attribute = AttributeName, + domain = Domain, + constraints = Constraints + }. + +%% @doc Derive a 1OP constraint from another 1OP constraint by adding constraints. +%% +%% Creates a new attribute constraint by adding additional constraints to an +%% existing 1OP constraint. The derived constraint applies all parent constraints +%% plus the additional ones. +-spec derive_1op(#attribute_constraint{}, [relational_constraint()]) -> #attribute_constraint{}. +derive_1op(#attribute_constraint{} = Parent1OPConstraint, AdditionalConstraints) -> + AllConstraints = Parent1OPConstraint#attribute_constraint.constraints ++ AdditionalConstraints, + #attribute_constraint{ + attribute = Parent1OPConstraint#attribute_constraint.attribute, + domain = Parent1OPConstraint#attribute_constraint.domain, + constraints = AllConstraints + }. + +%%% 1OPiC (1st Order Property in Context) Operations +%%% +%%% A 1OPiC is an attribute - a 1OP that assumes a specific meaning within +%%% the context of a particular relation (entity group). +%%% +%%% Key insight: The SAME 1OP (domain) can manifest as MULTIPLE 1OPiCs with +%%% DIFFERENT meanings in the same or different relations. +%%% +%%% Example: +%%% - 1OP: `compensation` (the abstract property of monetary compensation) +%%% - 1OPiC: `salary` in employees (base pay - one meaning of compensation) +%%% - 1OPiC: `commission` in employees (variable pay - another meaning) +%%% +%%% Both `salary` and `commission` draw values from `compensation`, but they +%%% have distinct semantic meanings in the context of the employees relation. +%%% +%%% At the logical level: +%%% - 1OP = Domain (value set) +%%% - 1OPiC = Attribute (contextualized appearance of a domain) +%%% - Schema entry `#{salary => compensation}` declares the 1OPiC relationship + +%% @doc Declare a 1OPiC (attribute) in a relation schema. +%% +%% A 1OPiC is a 1OP in a specific context - an attribute that: +%% 1. Draws its values from a specific domain (1OP) +%% 2. Has a particular meaning in this relation's context +%% +%% Multiple attributes can draw from the same domain but represent +%%% different meanings (e.g., salary and commission both from compensation). +-spec declare_1opic(atom(), atom() | #domain{}, map()) -> map(). +declare_1opic(AttributeName, Domain1OP, Schema) when is_atom(Domain1OP) -> + Schema#{AttributeName => Domain1OP}; +declare_1opic(AttributeName, #domain{name = DomainName}, Schema) -> + Schema#{AttributeName => DomainName}. + +%% @doc Validate that a value satisfies its 1OPiC's domain constraints. +%% +%% Checks that the value is a member of the domain (1OP) that the +%% attribute (1OPiC) draws from. +-spec validate_1opic(atom(), term(), map()) -> true | {false, term()}. +validate_1opic(AttributeName, Value, Schema) -> + case maps:get(AttributeName, Schema, undefined) of + undefined -> + {false, {unknown_attribute, AttributeName}}; + DomainName -> + %% Get the domain and test membership + case get_domain(DomainName) of + {ok, Domain} -> + Tuple = #{value => Value}, + case member(Tuple, Domain) of + true -> true; + false -> {false, {not_in_domain, Value, DomainName}} + end; + {error, Reason} -> + {false, {domain_not_found, DomainName, Reason}} + end + end. + +%%% Constraint Resolution + +%% @doc Resolve constraints for a relation against given values. +%% +%% @param Type The type of resolution (domain, relation, etc.) +%% @param RelationOrDomain The relation or domain +%% @param Values The values to check +%% @returns Resolution result +-spec resolve(atom(), #domain{} | #relation{}, map()) -> + {ok, boolean()} | {error, term()}. +resolve(domain, #domain{} = Domain, Values) -> + Result = member(Values, Domain), + {ok, Result}; +resolve(relation, #relation{} = Relation, Values) -> + Result = member(Values, Relation), + {ok, Result}; +resolve(constraint, Constraint, Binding) -> + evaluate_constraint(Constraint, Binding). + +%%% Tuple and Schema Validation (for operations.erl integration) + +%% @doc Validate a tuple against a relation's schema and constraints. +%% +%% Checks that: +%% 1. All required attributes are present +%% 2. Each attribute value is a member of its declared domain +%% 3. Each attribute value satisfies its 1OP constraints (if any) +%% +%% @param Tuple The tuple map to validate +%% @param Relation The relation with schema and constraints +%% @returns ok | {error, Reason} +-spec validate_tuple(map(), #relation{}) -> ok | {error, term()}. +validate_tuple(Tuple, #relation{schema = Schema, constraints = Constraints}) -> + case validate_tuple_against_schema(Tuple, Schema) of + ok -> + %% Also validate against attribute constraints (1OPs) + validate_tuple_against_constraints(Tuple, Constraints); + Error -> + Error + end. + +-spec validate_tuple_against_schema(map(), map()) -> ok | {error, term()}. +validate_tuple_against_schema(Tuple, Schema) -> + %% Check all schema attributes are present and valid + SchemaAttrs = maps:keys(Schema), + TupleAttrs = maps:keys(Tuple), + + %% Check for missing attributes + Missing = SchemaAttrs -- TupleAttrs, + case Missing of + [] -> + %% Check for extra attributes not in schema + Extra = TupleAttrs -- SchemaAttrs, + case Extra of + [] -> + %% Validate each attribute against its domain + validate_all_attributes(Tuple, Schema); + _ -> + {error, {extra_attributes, Extra}} + end; + _ -> + {error, {missing_attributes, Missing}} + end. + +-spec validate_all_attributes(map(), map()) -> ok | {error, term()}. +validate_all_attributes(Tuple, Schema) -> + Results = maps:fold( + fun(AttrName, Value, Acc) -> + case Acc of + {error, _} = Err -> Err; + ok -> + case validate_1opic(AttrName, Value, Schema) of + true -> ok; + {false, Reason} -> {error, {invalid_attribute, AttrName, Reason}} + end + end + end, + ok, + Tuple + ), + Results. + +-spec validate_tuple_against_constraints(map(), #relation_constraints{} | undefined) -> ok | {error, term()}. +validate_tuple_against_constraints(_Tuple, undefined) -> + %% No constraints defined + ok; +validate_tuple_against_constraints(Tuple, #relation_constraints{attribute_constraints = AttrConstraints}) -> + %% Validate each attribute against its constraint + validate_attribute_constraints(Tuple, AttrConstraints). + +-spec validate_attribute_constraints(map(), #{atom() => #attribute_constraint{}}) -> ok | {error, term()}. +validate_attribute_constraints(Tuple, AttrConstraints) when is_map(AttrConstraints) -> + maps:fold( + fun(AttrName, #attribute_constraint{domain = Domain, constraints = Constraints}, Acc) -> + case Acc of + {error, _} = Err -> Err; + ok -> + case maps:get(AttrName, Tuple, undefined) of + undefined -> + %% Attribute not in tuple - schema validation should catch this + ok; + Value -> + %% Validate: value ∈ domain AND additional constraints + validate_attribute_constraint(AttrName, Value, Domain, Constraints) + end + end + end, + ok, + AttrConstraints + ). + +-spec validate_attribute_constraint(atom(), term(), atom(), [relational_constraint()]) -> ok | {error, term()}. +validate_attribute_constraint(AttrName, Value, Domain, Constraints) -> + %% Step 1: Check domain membership + case get_domain(Domain) of + {ok, DomainRec} -> + ValueTuple = #{value => Value}, + case member(ValueTuple, DomainRec) of + true -> + %% Step 2: Apply additional constraints + validate_additional_constraints(AttrName, Value, Constraints); + false -> + {error, {not_in_domain, AttrName, Value, Domain}} + end; + {error, _} -> + {error, {unknown_domain, AttrName, Domain}} + end. + +-spec validate_additional_constraints(atom(), term(), [relational_constraint()]) -> ok | {error, term()}. +validate_additional_constraints(_AttrName, _Value, []) -> + %% No additional constraints + ok; +validate_additional_constraints(AttrName, Value, Constraints) -> + %% Evaluate each constraint + Context = #{value => Value}, + Results = [evaluate_constraint(C, Context) || C <- Constraints], + case lists:all(fun({ok, true}) -> true; (_) -> false end, Results) of + true -> ok; + false -> {error, {constraint_violation, AttrName, Value, Constraints}} + end. + +-spec validate_schema(map()) -> ok | {error, term()}. +validate_schema(Schema) -> + Results = maps:fold( + fun(AttrName, DomainName, Acc) -> + case Acc of + {error, _} = Err -> Err; + ok -> + case get_domain(DomainName) of + {ok, _Domain} -> ok; + {error, _} -> {error, {unknown_domain, AttrName, DomainName}} + end + end + end, + ok, + Schema + ), + Results. + +-spec build_membership_criteria(map()) -> map(). +build_membership_criteria(Schema) -> + maps:fold( + fun(AttrName, DomainName, Acc) -> + case get_domain(DomainName) of + {ok, Domain} -> + Test = case Domain#domain.membership_criteria of + #{test := TestFun} -> TestFun; + _ -> fun(_) -> true end + end, + Acc#{AttrName => #{domain => DomainName, test => Test}}; + {error, _} -> + %% Unknown domain - use permissive test + Acc#{AttrName => #{domain => DomainName, test => fun(_) -> true end}} + end + end, + #{}, + Schema + ). + +%%% Domain Registry +%%% +%%% A simple ETS-based registry for custom 1OPs (domains). +%%% This allows users to define new domains and have them recognized +%%% by the constraint system. + +%% @doc Register a custom domain (1OP) in the registry. +%% +%% After registration, the domain can be used in schemas and will be +%% recognized by validate_schema/1 and validate_1opic/3. +%% +%% @param Domain The #domain{} record to register +%% @returns ok +-spec register_domain(#domain{}) -> ok. +register_domain(#domain{name = Name} = Domain) -> + ensure_registry_exists(), + ets:insert(domain_registry, {Name, Domain}), + ok. + +-spec unregister_domain(atom()) -> ok. +unregister_domain(Name) -> + ensure_registry_exists(), + ets:delete(domain_registry, Name), + ok. + +-spec list_domains() -> [atom()]. +list_domains() -> + ensure_registry_exists(), + BuiltIn = [integer, natural, boolean, rational, string, atom], + Custom = [Name || {Name, _} <- ets:tab2list(domain_registry)], + BuiltIn ++ Custom. + +ensure_registry_exists() -> + case ets:whereis(domain_registry) of + undefined -> + ets:new(domain_registry, [named_table, public, set]); + _ -> + ok + end. + +lookup_domain(Name) -> + ensure_registry_exists(), + case ets:lookup(domain_registry, Name) of + [{Name, Domain}] -> {ok, Domain}; + [] -> get_builtin_domain(Name) + end. + +%%% Constraint Builders +%%% +%%% Convenience functions for building relational constraints. +%%% These create constraints in the form expected by create_1op/3. + +%% @doc Create a "less than" constraint: value < X +%% +%% Expressed relationally as: (value, X) ∈ less_than +-spec lt(term()) -> relational_constraint(). +lt(Value) -> + {member_of, less_than, #{left => {var, value}, right => Value}}. + +%% @doc Create a "less than or equal" constraint: value <= X +-spec lte(term()) -> relational_constraint(). +lte(Value) -> + {member_of, less_than_or_equal, #{left => {var, value}, right => Value}}. + +%% @doc Create a "greater than" constraint: value > X +%% +%% Expressed relationally as: (X, value) ∈ less_than +-spec gt(term()) -> relational_constraint(). +gt(Value) -> + {member_of, less_than, #{left => Value, right => {var, value}}}. + +%% @doc Create a "greater than or equal" constraint: value >= X +-spec gte(term()) -> relational_constraint(). +gte(Value) -> + {member_of, less_than_or_equal, #{left => Value, right => {var, value}}}. + +%% @doc Create an "equal" constraint: value = X +-spec eq(term()) -> relational_constraint(). +eq(Value) -> + {member_of, equal, #{left => {var, value}, right => Value}}. + +%% @doc Create a "not equal" constraint: value != X +-spec neq(term()) -> relational_constraint(). +neq(Value) -> + {member_of, not_equal, #{left => {var, value}, right => Value}}. + +%% @doc Create a range constraint: Min <= value < Max +%% +%% This is a conjunction of two relational constraints: +%% (Min, value) ∈ less_than_or_equal AND (value, Max) ∈ less_than +-spec between(term(), term()) -> relational_constraint(). +between(Min, Max) -> + {'and', [gte(Min), lt(Max)]}. + +%%% Constraint Propagation +%%% +%%% These functions support propagating constraints through relational operations. +%%% Constraints are stored in #relation_constraints{} records. + +%% @doc Create an empty constraints record. +%% +%% @returns Empty #relation_constraints{} with no constraints +-spec empty_constraints() -> #relation_constraints{}. +empty_constraints() -> + #relation_constraints{ + attribute_constraints = #{}, + tuple_constraints = [], + multi_tuple_constraints = [] + }. + +%% @doc Add an attribute constraint to a constraints record. +%% +%% @param Constraints The #relation_constraints{} record or undefined +%% @param AttrName The attribute name +%% @param AttrConstraint The #attribute_constraint{} to add +%% @returns Updated #relation_constraints{} +-spec add_attribute_constraint(#relation_constraints{} | undefined, atom(), #attribute_constraint{}) -> #relation_constraints{}. +add_attribute_constraint(undefined, AttrName, AttrConstraint) -> + #relation_constraints{ + attribute_constraints = #{AttrName => AttrConstraint}, + tuple_constraints = [], + multi_tuple_constraints = [] + }; +add_attribute_constraint(#relation_constraints{attribute_constraints = AttrConstraints} = Constraints, AttrName, AttrConstraint) -> + Constraints#relation_constraints{ + attribute_constraints = AttrConstraints#{AttrName => AttrConstraint} + }. + +%% @doc Merge two constraint records with AND semantics. +%% +%% Merges attribute constraints from both records. For attributes that appear +%% in both, combines constraints using derive_1op (conjunction). +%% +%% @param C1 First #relation_constraints{} or undefined +%% @param C2 Second #relation_constraints{} or undefined +%% @returns Merged #relation_constraints{} +-spec merge_constraints(#relation_constraints{} | undefined, #relation_constraints{} | undefined) -> #relation_constraints{}. +merge_constraints(undefined, undefined) -> + empty_constraints(); +merge_constraints(undefined, C2) when is_record(C2, relation_constraints) -> + C2; +merge_constraints(C1, undefined) when is_record(C1, relation_constraints) -> + C1; +merge_constraints(#relation_constraints{attribute_constraints = AttrConstraints1, + tuple_constraints = TupleConstraints1, + multi_tuple_constraints = MultiTupleConstraints1}, + #relation_constraints{attribute_constraints = AttrConstraints2, + tuple_constraints = TupleConstraints2, + multi_tuple_constraints = MultiTupleConstraints2}) -> + %% Merge attribute constraints + MergedAttrConstraints = maps:fold( + fun(Attr, Constraint2, Acc) -> + case maps:get(Attr, Acc, undefined) of + undefined -> + %% Attribute only in C2 + Acc#{Attr => Constraint2}; + Constraint1 -> + %% Attribute in both - merge constraints + MergedConstraint = derive_1op(Constraint1, Constraint2#attribute_constraint.constraints), + Acc#{Attr => MergedConstraint} + end + end, + AttrConstraints1, + AttrConstraints2 + ), + + #relation_constraints{ + attribute_constraints = MergedAttrConstraints, + tuple_constraints = TupleConstraints1 ++ TupleConstraints2, + multi_tuple_constraints = MultiTupleConstraints1 ++ MultiTupleConstraints2 + }. + +%% @doc Filter constraints to only keep those for specified attributes. +%% +%% Used by PROJECT to drop constraints for attributes that are projected away. +%% +%% @param Constraints The #relation_constraints{} or undefined +%% @param Attrs List of attribute names to keep +%% @returns Filtered #relation_constraints{} +-spec filter_constraints(#relation_constraints{} | undefined, [atom()]) -> #relation_constraints{}. +filter_constraints(undefined, _Attrs) -> + empty_constraints(); +filter_constraints(#relation_constraints{attribute_constraints = AttrConstraints} = Constraints, Attrs) -> + FilteredAttrConstraints = maps:with(Attrs, AttrConstraints), + Constraints#relation_constraints{ + attribute_constraints = FilteredAttrConstraints + }. + +%% @doc Rename attribute names in constraints according to a mapping. +%% +%% Updates both the attribute field in #attribute_constraint{} and any +%% {var, AttrName} references inside constraint terms. Used by RENAME operator. +%% +%% @param Constraints The #relation_constraints{} or undefined +%% @param RenameMappings Map of #{old_name => new_name} +%% @returns #relation_constraints{} with renamed attributes +-spec rename_constraint_attrs(#relation_constraints{} | undefined, map()) -> #relation_constraints{}. +rename_constraint_attrs(undefined, _RenameMappings) -> + empty_constraints(); +rename_constraint_attrs(#relation_constraints{attribute_constraints = AttrConstraints} = Constraints, RenameMappings) -> + RenamedAttrConstraints = maps:fold( + fun(OldAttr, #attribute_constraint{constraints = Cs} = AttrConstraint, Acc) -> + NewAttr = maps:get(OldAttr, RenameMappings, OldAttr), + %% Also rename variable references inside constraint terms + RenamedConstraints = [rename_vars_in_constraint(C, RenameMappings) || C <- Cs], + RenamedConstraint = AttrConstraint#attribute_constraint{ + attribute = NewAttr, + constraints = RenamedConstraints + }, + Acc#{NewAttr => RenamedConstraint} + end, + #{}, + AttrConstraints + ), + Constraints#relation_constraints{ + attribute_constraints = RenamedAttrConstraints + }. + +%% @doc Infer constraints from a predicate. +%% +%% For opaque function predicates, returns empty constraints (cannot analyze). +%% For declarative predicate terms, extracts constraint structure and creates +%% #attribute_constraint{} records using domain information from schema. +%% +%% Declarative predicate format: +%% {attr, AttrName, Op, Value} - e.g., {attr, age, lt, 50} +%% {'and', [Pred1, Pred2, ...]} +%% {'or', [Pred1, Pred2, ...]} +%% @param Predicate The predicate (function or declarative term) +%% @param Schema The relation schema (for context, maps attr => domain) +%% @returns Inferred #relation_constraints{} +-spec infer_constraints_from_pred(term(), map()) -> #relation_constraints{}. +infer_constraints_from_pred(Predicate, _Schema) when is_function(Predicate) -> + %% Opaque function - cannot extract constraints + empty_constraints(); +infer_constraints_from_pred({attr, AttrName, Op, Value}, Schema) -> + %% Declarative constraint on single attribute + Constraint = op_to_constraint(Op, Value), + Domain = maps:get(AttrName, Schema, undefined), + case Domain of + undefined -> + %% Attribute not in schema - cannot create constraint + empty_constraints(); + _ -> + AttrConstraint = #attribute_constraint{ + attribute = AttrName, + domain = Domain, + constraints = [Constraint] + }, + #relation_constraints{ + attribute_constraints = #{AttrName => AttrConstraint}, + tuple_constraints = [], + multi_tuple_constraints = [] + } + end; +infer_constraints_from_pred({'and', Predicates}, Schema) -> + %% Conjunction - merge all constraints + lists:foldl( + fun(Pred, Acc) -> + PredConstraints = infer_constraints_from_pred(Pred, Schema), + merge_constraints(Acc, PredConstraints) + end, + empty_constraints(), + Predicates + ); +infer_constraints_from_pred({'or', _Predicates}, _Schema) -> + %% Disjunction - cannot easily propagate as attribute-level constraints + %% Would need to track as full formula, not per-attribute + empty_constraints(); +infer_constraints_from_pred(_, _Schema) -> + %% Unknown format + empty_constraints(). + +%% Helper: rename variable references inside a constraint term +rename_vars_in_constraint({member_of, RelName, Binding}, Mappings) -> + RenamedBinding = maps:map( + fun(_K, {var, VarName}) -> + {var, maps:get(VarName, Mappings, VarName)}; + (_K, V) -> + V + end, + Binding + ), + {member_of, RelName, RenamedBinding}; +rename_vars_in_constraint({'and', Constraints}, Mappings) -> + {'and', [rename_vars_in_constraint(C, Mappings) || C <- Constraints]}; +rename_vars_in_constraint({'or', Constraints}, Mappings) -> + {'or', [rename_vars_in_constraint(C, Mappings) || C <- Constraints]}; +rename_vars_in_constraint({'not', Constraint, Universe}, Mappings) -> + {'not', rename_vars_in_constraint(Constraint, Mappings), Universe}; +rename_vars_in_constraint(Other, _Mappings) -> + Other. + +%% Helper: convert declarative operator to relational constraint +op_to_constraint(lt, Value) -> lt(Value); +op_to_constraint(lte, Value) -> lte(Value); +op_to_constraint(gt, Value) -> gt(Value); +op_to_constraint(gte, Value) -> gte(Value); +op_to_constraint(eq, Value) -> eq(Value); +op_to_constraint(neq, Value) -> neq(Value); +op_to_constraint(between, {Min, Max}) -> between(Min, Max); +op_to_constraint(_, _Value) -> {'and', []}. % No-op constraint + +%%% Internal trash functions + +%% Determine cardinality for comparison relations +comparison_cardinality(boolean) -> + {finite, 1}; % Only (false, true) for less_than +comparison_cardinality(natural) -> + aleph_zero; % Countably infinite pairs +comparison_cardinality(integer) -> + aleph_zero; +comparison_cardinality(rational) -> + continuum; % Uncountably infinite +comparison_cardinality(_) -> + aleph_zero. % Default assumption + +%% Get cardinality of a base domain +domain_cardinality(boolean) -> {finite, 2}; +domain_cardinality(natural) -> aleph_zero; +domain_cardinality(integer) -> aleph_zero; +domain_cardinality(rational) -> continuum; +domain_cardinality(_) -> aleph_zero. + +%% Validate tuple against criteria map +validate_tuple_against_criteria(Tuple, Criteria) when is_map(Criteria) -> + maps:fold( + fun(Attr, Constraint, Acc) -> + Acc andalso validate_attribute(maps:get(Attr, Tuple, undefined), Constraint) + end, + true, + Criteria + ). + +validate_attribute(undefined, _) -> false; +validate_attribute(Value, #{test := Test}) -> Test(#{value => Value}); +validate_attribute(Value, Constraint) when is_function(Constraint, 1) -> + Constraint(Value); +validate_attribute(_, _) -> true. + +%% Check if tuple exists in a mutable relation +relation_contains(_Tuple, #relation{tree = undefined}) -> + false; +relation_contains(Tuple, #relation{tree = Tree}) -> + %% Would need to hash the tuple and check the Merkle tree + %% This is a placeholder - actual implementation depends on storage + TupleHash = erlang:phash2(Tuple), + gb_trees:is_defined(TupleHash, Tree). + +%% Apply variable substitution to a binding +apply_substitution(Binding, Substitution) -> + maps:map( + fun(_K, {var, VarName}) -> + maps:get(VarName, Substitution, {var, VarName}); + (_K, V) -> + V + end, + Binding + ). + +%% Find variables in a binding +find_variables(Binding) -> + maps:fold( + fun(_K, {var, VarName}, Acc) -> [VarName | Acc]; + (_K, _, Acc) -> Acc + end, + [], + Binding + ). + +%% Find a substitution that satisfies membership +find_satisfying_substitution(_Tuple, _Variables, _Relation, _Substitution) -> + %% This would require enumeration via generators + %% Placeholder for now - full implementation needs generator integration + false. + +%% Get schema from domain or relation +get_schema(#domain{schema = S}) -> S; +get_schema(#relation{schema = S}) -> S. + +%% Get name from domain or relation +get_name(#domain{name = N}) -> N; +get_name(#relation{name = N}) -> N. + +%% Get lineage from relation +get_lineage(#relation{lineage = L}) -> L; +get_lineage(#domain{name = N}) -> {base, N}. + +%% Check if schemas are compatible (same attributes with compatible domains) +schemas_compatible(S1, S2) -> + %% Schemas are compatible if they have the same structure + maps:keys(S1) =:= maps:keys(S2). + +%% Merge two compatible schemas +merge_schemas(S1, _S2) -> + %% For now, just use the first schema (they should be identical) + S1. + +%% Get cardinality from domain or relation +get_cardinality(#domain{cardinality = C}) -> C; +get_cardinality(#relation{cardinality = C}) -> C. + +%% Compute cardinality of complement +complement_cardinality(R, Universe) -> + RC = get_cardinality(R), + UC = get_cardinality(Universe), + case {RC, UC} of + {{finite, N}, {finite, M}} -> {finite, max(0, M - N)}; + {{finite, _}, Infinite} -> Infinite; + _ -> UC + end. + +%% Compute cardinality of intersection +intersection_cardinality(R1, R2) -> + C1 = get_cardinality(R1), + C2 = get_cardinality(R2), + case {C1, C2} of + {{finite, N}, _} -> {finite, N}; % Upper bound + {_, {finite, M}} -> {finite, M}; % Upper bound + _ -> min_cardinality(C1, C2) + end. + +%% Compute cardinality of union +union_cardinality(R1, R2) -> + C1 = get_cardinality(R1), + C2 = get_cardinality(R2), + case {C1, C2} of + {{finite, N}, {finite, M}} -> {finite, N + M}; % Upper bound + _ -> max_cardinality(C1, C2) + end. + +min_cardinality({finite, N}, {finite, M}) -> {finite, min(N, M)}; +min_cardinality({finite, N}, _) -> {finite, N}; +min_cardinality(_, {finite, M}) -> {finite, M}; +min_cardinality(aleph_zero, _) -> aleph_zero; +min_cardinality(_, aleph_zero) -> aleph_zero; +min_cardinality(continuum, continuum) -> continuum. + +max_cardinality({finite, N}, {finite, M}) -> {finite, max(N, M)}; +max_cardinality(continuum, _) -> continuum; +max_cardinality(_, continuum) -> continuum; +max_cardinality(aleph_zero, _) -> aleph_zero; +max_cardinality(_, aleph_zero) -> aleph_zero. + +%% Convert constraints to a membership test function +constraints_to_test([], _BaseDomain) -> + fun(_) -> true end; +constraints_to_test(Constraints, _BaseDomain) -> + fun(Tuple) -> + lists:all( + fun(Constraint) -> + case evaluate_constraint(Constraint, Tuple) of + {ok, true} -> true; + _ -> false + end + end, + Constraints + ) + end. + +%% Evaluate a relational constraint +evaluate_constraint({member_of, RelName, Binding}, Context) when is_atom(RelName) -> + %% Get the relation and test membership + BoundBinding = bind_variables(Binding, Context), + case get_comparison_relation(RelName) of + {ok, Relation} -> + {ok, member(BoundBinding, Relation)}; + {error, _} = Err -> + Err + end; +evaluate_constraint({member_of, #domain{} = Rel, Binding}, Context) -> + BoundBinding = bind_variables(Binding, Context), + {ok, member(BoundBinding, Rel)}; +evaluate_constraint({'not', Constraint, Universe}, Context) -> + case evaluate_constraint(Constraint, Context) of + {ok, true} -> {ok, false}; + {ok, false} -> + %% Must also be in universe + case evaluate_constraint({member_of, Universe, Context}, Context) of + {ok, true} -> {ok, true}; + _ -> {ok, false} + end; + Error -> Error + end; +evaluate_constraint({'and', Constraints}, Context) -> + Results = [evaluate_constraint(C, Context) || C <- Constraints], + case lists:all(fun({ok, true}) -> true; (_) -> false end, Results) of + true -> {ok, true}; + false -> {ok, false} + end; +evaluate_constraint({'or', Constraints}, Context) -> + Results = [evaluate_constraint(C, Context) || C <- Constraints], + case lists:any(fun({ok, true}) -> true; (_) -> false end, Results) of + true -> {ok, true}; + false -> {ok, false} + end; +evaluate_constraint(_, _) -> + {error, unsupported_constraint}. + +%% Bind variables in a binding using context +bind_variables(Binding, Context) -> + maps:map( + fun(_K, {var, VarName}) -> + maps:get(VarName, Context, maps:get(value, Context, undefined)); + (_K, V) -> + V + end, + Binding + ). + +%% Get a comparison relation by name +get_comparison_relation(less_than) -> {ok, less_than(integer)}; +get_comparison_relation(less_than_or_equal) -> {ok, less_than_or_equal(integer)}; +get_comparison_relation(greater_than) -> {ok, greater_than(integer)}; +get_comparison_relation(greater_than_or_equal) -> {ok, greater_than_or_equal(integer)}; +get_comparison_relation(equal) -> {ok, equal(integer)}; +get_comparison_relation(not_equal) -> {ok, not_equal(integer)}; +get_comparison_relation(Name) -> {error, {unknown_relation, Name}}. + +%% Get a domain by name - checks registry first, then built-in +get_domain(Name) -> + lookup_domain(Name). + +%% Get built-in domain by name +get_builtin_domain(integer) -> {ok, integer_domain()}; +get_builtin_domain(natural) -> {ok, natural_domain()}; +get_builtin_domain(boolean) -> {ok, boolean_domain()}; +get_builtin_domain(rational) -> {ok, rational_domain()}; +get_builtin_domain(string) -> {ok, string_domain()}; +get_builtin_domain(atom) -> {ok, atom_domain()}; +get_builtin_domain(Name) -> {error, {unknown_domain, Name}}. + +%% Built-in domain definitions +integer_domain() -> + #domain{ + name = integer, + schema = #{value => integer}, + generator = {primitive, integer}, + membership_criteria = #{ + test => fun(#{value := V}) -> is_integer(V) end + }, + cardinality = aleph_zero + }. + +natural_domain() -> + #domain{ + name = natural, + schema = #{value => natural}, + generator = {primitive, natural}, + membership_criteria = #{ + test => fun(#{value := V}) -> is_integer(V) andalso V >= 0 end + }, + cardinality = aleph_zero + }. + +boolean_domain() -> + #domain{ + name = boolean, + schema = #{value => boolean}, + generator = {primitive, boolean}, + membership_criteria = #{ + test => fun(#{value := V}) -> V =:= true orelse V =:= false end + }, + cardinality = {finite, 2} + }. + +rational_domain() -> + #domain{ + name = rational, + schema = #{numerator => integer, denominator => integer}, + generator = {primitive, rational}, + membership_criteria = #{ + test => fun(#{numerator := N, denominator := D}) -> + is_integer(N) andalso is_integer(D) andalso D /= 0 + end + }, + cardinality = aleph_zero + }. + +string_domain() -> + #domain{ + name = string, + schema = #{value => string}, + generator = {primitive, string}, + membership_criteria = #{ + test => fun(#{value := V}) -> is_list(V) orelse is_binary(V) end + }, + cardinality = aleph_zero + }. + +atom_domain() -> + #domain{ + name = atom, + schema = #{value => atom}, + generator = {primitive, atom}, + membership_criteria = #{ + test => fun(#{value := V}) -> is_atom(V) end + }, + cardinality = aleph_zero + }. + +%% Compute cardinality for constrained domains +constrained_cardinality(_BaseDomain, []) -> + aleph_zero; +constrained_cardinality(_BaseDomain, Constraints) -> + %% Analyze constraints to determine if finite + case lists:any(fun is_finitely_constraining/1, Constraints) of + true -> {finite, unknown}; % Finite but unknown size + false -> aleph_zero + end. + +is_finitely_constraining({member_of, equal, _}) -> true; +is_finitely_constraining({'and', Cs}) -> lists:any(fun is_finitely_constraining/1, Cs); +is_finitely_constraining({'or', Cs}) -> lists:all(fun is_finitely_constraining/1, Cs); +is_finitely_constraining(_) -> false. diff --git a/src/operations.erl b/src/operations.erl index e224800..b61cfe2 100644 --- a/src/operations.erl +++ b/src/operations.erl @@ -40,7 +40,7 @@ -module(operations). -include("../include/operations.hrl"). -export([ - create_database/1, + create_database/1, create_relation/3, create_immutable_relation/2, create_tuple/3, @@ -51,11 +51,11 @@ clear_relation/3, retract_relation/2, retract_relation/3, - get_relation_hash/2, - get_relations/1, - hashes_from_tuple/1, - get_tuples_iterator/2, - get_iterator_from_generator/2, + 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,7 +69,7 @@ theta_join_iterator/3, %% Materialization (eager) materialize/3 -]). + ]). %% @private %% @doc Compute SHA-256 hash of an Erlang term. @@ -118,7 +118,14 @@ 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). + + %% Validate tuple against relation schema before insertion + case constraint:validate_tuple(Tuple, RelationRecord) of + ok -> + create_tuple_internal(Database, RelationName, Tuple, RelationRecord); + {error, Reason} -> + {error, {constraint_violation, Reason}} + end. %% @private %% Internal function for tuple creation @@ -374,16 +381,27 @@ retract_relation(Database, Name, Transact) -> %% @see create_tuple/3 %% @see retract_relation/2 create_relation(Database, Name, Definition) when is_record(Database, database_state) -> + %% Validate schema before creating relation + case constraint:validate_schema(Definition) of + {error, Reason} -> + {error, {invalid_schema, Reason}}; + ok -> + create_relation_internal(Database, Name, Definition) + end. + +%% @private +%% Internal function for relation creation after schema validation +create_relation_internal(Database, Name, Definition) -> F = fun() -> %% Create relation with empty tree - %% The meaning of a relation needs to be attached to its name + %% The meaning of a relation needs to be attached to its name %% It may sound nominalistic but that is a physical concern. %% 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 + %% 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, @@ -395,15 +413,18 @@ create_relation(Database, Name, Definition) when is_record(Database, database_st spawn(fun() -> generator_iterator_loop(InnerGen) end) end, + %% Build membership criteria from schema (1OPiC declarations) + MembershipCriteria = constraint:build_membership_criteria(Definition), + NewRelation = #relation{ hash = RelationHash, name = Name, tree = undefined, schema = Definition, - constraints = #{}, % No constraints by default + constraints = constraint:empty_constraints(), % No constraints by default cardinality = {finite, 0}, % Empty relation generator = GeneratorFun, % Function generator for finite relations - membership_criteria = #{}, % No membership criteria by default + membership_criteria = MembershipCriteria, % Domain membership from schema provenance = build_base_provenance(Definition, Name), % Base relation provenance lineage = {base, Name} % Base relation lineage }, @@ -569,7 +590,7 @@ create_immutable_relation(Database, Specification) name = Name, tree = undefined, % Immutable relations have no merkle tree schema = Schema, - constraints = #{}, % No constraints by default + constraints = constraint:empty_constraints(), % No constraints by default cardinality = Cardinality, generator = Generator, membership_criteria = MembershipCriteria, diff --git a/src/relational_operators.erl b/src/relational_operators.erl index 82e1a47..93bdf6d 100644 --- a/src/relational_operators.erl +++ b/src/relational_operators.erl @@ -106,25 +106,44 @@ take(SourceRelation, N) when is_record(SourceRelation, relation), is_integer(N), %% Creates an ephemeral relation containing only tuples that satisfy the %% predicate. The generator closure captures the source relation and predicate. %% +%% Constraints are propagated: source constraints are preserved, and new +%% constraints may be inferred from declarative predicates. +%% +%% Predicate formats: +%% - fun((map()) -> boolean()) - opaque function, no inference +%% - {attr, AttrName, Op, Value} - declarative, infers constraints +%% - {'and', [Pred1, ...]} - conjunction of declarative predicates +%% %% @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) -> +%% @param Predicate Function or declarative term +%% @returns Ephemeral #relation{} with generator and propagated constraints +-spec select(#relation{}, fun((map()) -> boolean()) | term()) -> #relation{}. +select(SourceRelation, Predicate) when is_record(SourceRelation, relation) -> Name = generate_ephemeral_name(), + %% Build actual filter function from predicate + FilterFun = predicate_to_function(Predicate), + SourceGen = SourceRelation#relation.generator, GeneratorFun = fun() -> SourceIter = SourceGen(), - operations:select_iterator(SourceIter, Predicate) + operations:select_iterator(SourceIter, FilterFun) end, + %% Infer constraints from predicate and merge with source constraints + Schema = SourceRelation#relation.schema, + InferredConstraints = constraint:infer_constraints_from_pred(Predicate, Schema), + MergedConstraints = constraint:merge_constraints( + SourceRelation#relation.constraints, + InferredConstraints + ), + #relation{ hash = <<>>, name = Name, tree = undefined, - schema = SourceRelation#relation.schema, - constraints = SourceRelation#relation.constraints, + schema = Schema, + constraints = MergedConstraints, cardinality = unknown, % Cannot determine without evaluation generator = GeneratorFun, membership_criteria = #{}, @@ -137,9 +156,12 @@ select(SourceRelation, Predicate) when is_record(SourceRelation, relation), is_f %% Creates an ephemeral relation containing only the specified attributes. %% The generator closure captures the source relation and attribute list. %% +%% Constraints are filtered to only include those for projected attributes. +%% Constraints on attributes that are projected away are dropped. +%% %% @param SourceRelation Source #relation{} record %% @param Attributes List of attribute names to keep -%% @returns Ephemeral #relation{} with generator +%% @returns Ephemeral #relation{} with generator and filtered constraints -spec project(#relation{}, [atom()]) -> #relation{}. project(SourceRelation, Attributes) when is_record(SourceRelation, relation), is_list(Attributes) -> Name = generate_ephemeral_name(), @@ -147,6 +169,12 @@ project(SourceRelation, Attributes) when is_record(SourceRelation, relation), is %% Compute projected schema ProjectedSchema = maps:with(Attributes, SourceRelation#relation.schema), + %% Filter constraints to only projected attributes + FilteredConstraints = constraint:filter_constraints( + SourceRelation#relation.constraints, + Attributes + ), + SourceGen = SourceRelation#relation.generator, GeneratorFun = fun() -> SourceIter = SourceGen(), @@ -158,7 +186,7 @@ project(SourceRelation, Attributes) when is_record(SourceRelation, relation), is name = Name, tree = undefined, schema = ProjectedSchema, - constraints = SourceRelation#relation.constraints, + constraints = FilteredConstraints, cardinality = SourceRelation#relation.cardinality, generator = GeneratorFun, membership_criteria = #{}, @@ -171,10 +199,13 @@ project(SourceRelation, Attributes) when is_record(SourceRelation, relation), is %% Creates an ephemeral relation by joining two relations on a common attribute. %% The generator closure captures both source relations and the join attribute. %% +%% Constraints from both relations are merged with AND semantics. +%% If both relations have constraints on the same attribute, both are kept. +%% %% @param LeftRelation Left #relation{} record %% @param RightRelation Right #relation{} record %% @param JoinAttribute Attribute name to join on -%% @returns Ephemeral #relation{} with generator +%% @returns Ephemeral #relation{} with generator and merged constraints -spec join(#relation{}, #relation{}, atom()) -> #relation{}. join(LeftRelation, RightRelation, JoinAttribute) when is_record(LeftRelation, relation), @@ -190,6 +221,12 @@ join(LeftRelation, RightRelation, JoinAttribute) %% Merge schemas MergedSchema = maps:merge(LeftRelation#relation.schema, RightRelation#relation.schema), + %% Merge constraints from both relations (AND semantics) + MergedConstraints = constraint:merge_constraints( + LeftRelation#relation.constraints, + RightRelation#relation.constraints + ), + LeftGen = LeftRelation#relation.generator, RightGen = RightRelation#relation.generator, GeneratorFun = fun() -> @@ -203,7 +240,7 @@ join(LeftRelation, RightRelation, JoinAttribute) name = Name, tree = undefined, schema = MergedSchema, - constraints = #{}, + constraints = MergedConstraints, cardinality = unknown, generator = GeneratorFun, membership_criteria = #{}, @@ -216,15 +253,17 @@ join(LeftRelation, RightRelation, JoinAttribute) %% Creates an ephemeral relation by joining two relations with an arbitrary %% predicate. The generator closure captures both source relations and predicate. %% +%% Constraints are merged from both relations, and additional constraints may +%% be inferred from declarative predicates. +%% %% @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{}. +%% @param Predicate Function or declarative term +%% @returns Ephemeral #relation{} with generator and merged constraints +-spec theta_join(#relation{}, #relation{}, fun((map(), map()) -> boolean()) | term()) -> #relation{}. theta_join(LeftRelation, RightRelation, Predicate) when is_record(LeftRelation, relation), - is_record(RightRelation, relation), - is_function(Predicate, 2) -> + is_record(RightRelation, relation) -> Name = list_to_atom( "theta_join_" ++ atom_to_list(LeftRelation#relation.name) ++ "_" ++ @@ -234,12 +273,26 @@ theta_join(LeftRelation, RightRelation, Predicate) MergedSchema = maps:merge(LeftRelation#relation.schema, RightRelation#relation.schema), + %% Merge constraints from both relations + MergedConstraints = constraint:merge_constraints( + LeftRelation#relation.constraints, + RightRelation#relation.constraints + ), + + %% Infer additional constraints from predicate + InferredConstraints = constraint:infer_constraints_from_pred(Predicate, MergedSchema), + FinalConstraints = constraint:merge_constraints(MergedConstraints, InferredConstraints), + + %% Build filter function from predicate (for theta join, needs 2-arity) + %% The theta_join_iterator expects a 2-arity function + FilterFun = theta_predicate_to_function(Predicate), + LeftGen = LeftRelation#relation.generator, RightGen = RightRelation#relation.generator, GeneratorFun = fun() -> LeftIter = LeftGen(), RightIter = RightGen(), - operations:theta_join_iterator(LeftIter, RightIter, Predicate) + operations:theta_join_iterator(LeftIter, RightIter, FilterFun) end, #relation{ @@ -247,7 +300,7 @@ theta_join(LeftRelation, RightRelation, Predicate) name = Name, tree = undefined, schema = MergedSchema, - constraints = #{}, + constraints = FinalConstraints, cardinality = unknown, generator = GeneratorFun, membership_criteria = #{}, @@ -260,9 +313,12 @@ theta_join(LeftRelation, RightRelation, Predicate) %% Creates an ephemeral relation with attributes renamed according to the mapping. %% The generator closure captures the source relation and rename mappings. %% +%% Constraints are updated: both the map keys and any {var, AttrName} references +%% inside constraint terms are renamed according to the mapping. +%% %% @param SourceRelation Source #relation{} record %% @param RenameMappings Map of old_name => new_name (atoms or strings) -%% @returns Ephemeral #relation{} with generator +%% @returns Ephemeral #relation{} with generator and renamed constraints -spec rename(#relation{}, #{atom() => atom()}) -> #relation{}. rename(SourceRelation, RenameMappings) when is_record(SourceRelation, relation), is_map(RenameMappings) -> Name = generate_ephemeral_name(), @@ -281,6 +337,12 @@ rename(SourceRelation, RenameMappings) when is_record(SourceRelation, relation), RenameMappings ), + %% Rename constraint attributes (both keys and internal variable references) + RenamedConstraints = constraint:rename_constraint_attrs( + SourceRelation#relation.constraints, + RenameMappings + ), + SourceGen = SourceRelation#relation.generator, GeneratorFun = fun() -> SourceIter = SourceGen(), @@ -292,7 +354,7 @@ rename(SourceRelation, RenameMappings) when is_record(SourceRelation, relation), name = Name, tree = undefined, schema = RenamedSchema, - constraints = SourceRelation#relation.constraints, + constraints = RenamedConstraints, cardinality = SourceRelation#relation.cardinality, generator = GeneratorFun, membership_criteria = #{}, @@ -364,3 +426,65 @@ rename_loop(SourceIter, RenameMappings) -> operations:close_iterator(SourceIter), Caller ! ok end. + +%% @private +%% @doc Convert a predicate (function or declarative term) to a filter function. +%% +%% Supports: +%% - fun((map()) -> boolean()) - returns as-is +%% - {attr, AttrName, Op, Value} - single attribute comparison +%% - {'and', [Pred1, ...]} - conjunction +%% - {'or', [Pred1, ...]} - disjunction +predicate_to_function(Predicate) when is_function(Predicate, 1) -> + Predicate; +predicate_to_function({attr, AttrName, Op, Value}) -> + fun(Tuple) -> + AttrValue = maps:get(AttrName, Tuple, undefined), + apply_comparison(Op, AttrValue, Value) + end; +predicate_to_function({'and', Predicates}) -> + Funs = [predicate_to_function(P) || P <- Predicates], + fun(Tuple) -> + lists:all(fun(F) -> F(Tuple) end, Funs) + end; +predicate_to_function({'or', Predicates}) -> + Funs = [predicate_to_function(P) || P <- Predicates], + fun(Tuple) -> + lists:any(fun(F) -> F(Tuple) end, Funs) + end; +predicate_to_function(_) -> + %% Unknown format - accept all + fun(_) -> true end. + +%% @private +%% @doc Apply a comparison operation. +apply_comparison(lt, A, B) -> A < B; +apply_comparison(lte, A, B) -> A =< B; +apply_comparison(gt, A, B) -> A > B; +apply_comparison(gte, A, B) -> A >= B; +apply_comparison(eq, A, B) -> A =:= B; +apply_comparison(neq, A, B) -> A =/= B; +apply_comparison(between, A, {Min, Max}) -> A >= Min andalso A < Max; +apply_comparison(_, _, _) -> true. + +%% @private +%% @doc Convert a theta predicate to a 2-arity filter function. +%% +%% Theta join predicates operate on merged tuples from both relations. +%% For declarative predicates, we convert to a function that works on +%% the merged tuple (left and right tuples combined). +theta_predicate_to_function(Predicate) when is_function(Predicate, 2) -> + Predicate; +theta_predicate_to_function(Predicate) when is_function(Predicate, 1) -> + %% Convert 1-arity function to 2-arity by merging tuples + fun(LeftTuple, RightTuple) -> + MergedTuple = maps:merge(LeftTuple, RightTuple), + Predicate(MergedTuple) + end; +theta_predicate_to_function(DeclarativePred) -> + %% Convert declarative predicate to 1-arity first, then wrap + OneTupleFun = predicate_to_function(DeclarativePred), + fun(LeftTuple, RightTuple) -> + MergedTuple = maps:merge(LeftTuple, RightTuple), + OneTupleFun(MergedTuple) + end. |
