Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,20 @@ Manages the "is a" hierarchy of class nodes in the ontology.
transaction (the seam's tier-1 contract) and are the prerequisite for atomic
`add_relationship` / `mutate/1`. See
`docs/designs/atomic-add-relationship-primitives-design.md`.
- `validate_template_scope_in_txn/3` (template_nref, source_class,
target_class) — in-transaction helper (aborts on failure): confirms the
template is a `kind=template` node whose parent class is in the source's or
target's taxonomic ancestry, else aborts `{template_class_not_in_ancestry,
…}` / `{invalid_template, …, Reason}`. Called by
`graphdb_instance:add_relationship` to validate Connection-arc template scope
(relocated here from `graphdb_instance` — it is pure class-domain logic).
- `search_class_taxonomy/2` (class_nref, attr_nref) — walks a class and its
taxonomy ancestors (nearest-first) for the first bound AVP, returning
`{ok, FoundClassNref, Value} | not_found`. Reads via the public
`get_class/1` + `ancestors/1` (not an in-txn primitive). Backs
`graphdb_instance`'s Priority-2 (class-bound) attribute inheritance
(relocated here from `graphdb_instance`); the per-instance membership
orchestration and ambiguity policy stay in `graphdb_instance`.

### `graphdb_instance` — Instance & Compositional Hierarchy

Expand Down
97 changes: 93 additions & 4 deletions apps/graphdb/src/graphdb_class.erl
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,15 @@
default_template/1,
default_template_in_txn/1,
is_instantiable/1,
%% Class-of resolution helper (used by graphdb_instance to validate
%% Template AVP class scope on Connection arcs)
%% Class-ancestry + template-scope helpers (used by graphdb_instance
%% to validate Template AVP class scope on Connection arcs)
class_in_ancestry/2,
class_in_ancestry_in_txn/2,
%% Inheritance
inherited_qcs/1
validate_template_scope_in_txn/3,
%% Inheritance (search_class_taxonomy backs graphdb_instance's
%% Priority-2 class-bound attribute resolution)
inherited_qcs/1,
search_class_taxonomy/2
]).

%%---------------------------------------------------------------------
Expand Down Expand Up @@ -854,6 +857,92 @@ walk_ancestors_in_txn([Nref | Rest], Visited, Acc) ->
end.


%%-----------------------------------------------------------------------------
%% validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> ok
%% (aborts invalid_template / template_class_not_in_ancestry)
%%
%% Confirms TemplateNref resolves to a kind=template node whose parent class is
%% in SourceClass's or TargetClass's taxonomic ancestry. Tier-1 in-transaction
%% helper: assumes it runs inside an active mnesia activity and signals failure
%% via mnesia:abort/1. Used by graphdb_instance to validate the Template AVP
%% class scope on Connection arcs.
%%-----------------------------------------------------------------------------
validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) ->
case get_template_in_txn(TemplateNref) of
{ok, #node{parents = TmplParents}} ->
TmplClass = head_parent(TmplParents),
InSource = class_in_ancestry_in_txn(TmplClass, SourceClass),
InTarget = class_in_ancestry_in_txn(TmplClass, TargetClass),
case InSource orelse InTarget of
true -> ok;
false -> mnesia:abort({template_class_not_in_ancestry,
TemplateNref, TmplClass, SourceClass, TargetClass})
end;
{error, Reason} ->
mnesia:abort({invalid_template, TemplateNref, Reason})
end.

%% head_parent(Parents) -> integer() | undefined
%%
%% First element of a node's parents cache, or undefined when empty.
head_parent([]) -> undefined;
head_parent([P | _]) -> P.


%%-----------------------------------------------------------------------------
%% search_class_taxonomy(ClassNref, AttrNref) ->
%% {ok, FoundClassNref, Value} | not_found
%%
%% Walks ClassNref and its taxonomy ancestors (nearest-first), returning the
%% first bound-AVP match together with the class nref where it was found. Used
%% by graphdb_instance's attribute-inheritance resolution (Priority 2: class-
%% level bound values). Runs in the caller's process and reads via the public
%% get_class/1 + ancestors/1 reads (behaviour-identical to its prior home in
%% graphdb_instance).
%%-----------------------------------------------------------------------------
search_class_taxonomy(ClassNref, AttrNref) ->
case get_class(ClassNref) of
{ok, #node{attribute_value_pairs = AVPs}} ->
case find_avp_value(AVPs, AttrNref) of
{ok, V} ->
{ok, ClassNref, V};
not_found ->
case ancestors(ClassNref) of
{ok, Ancestors} ->
search_first_in_ancestors(Ancestors, AttrNref);
_ ->
not_found
end
end;
_ ->
not_found
end.

search_first_in_ancestors([], _AttrNref) ->
not_found;
search_first_in_ancestors(
[#node{nref = N, attribute_value_pairs = AVPs} | Rest], AttrNref) ->
case find_avp_value(AVPs, AttrNref) of
{ok, V} -> {ok, N, V};
not_found -> search_first_in_ancestors(Rest, AttrNref)
end.

%% find_avp_value(AVPs, AttrNref) -> {ok, Value} | not_found
%%
%% First AVP entry matching AttrNref with a bound (non-undefined) value. An
%% entry with value => undefined is a QC declaration, not a resolved value, and
%% reads as not_found. (Duplicated from graphdb_instance per the codebase's
%% deliberate per-worker micro-helper convention — no shared util module.)
find_avp_value([], _AttrNref) ->
not_found;
find_avp_value([#{attribute := A, value := V} | _], A) when V =/= undefined ->
{ok, V};
find_avp_value([#{attribute := A} | _], A) ->
not_found;
find_avp_value([_ | Rest], AttrNref) ->
find_avp_value(Rest, AttrNref).


%%-----------------------------------------------------------------------------
%% do_validate_parent(ParentNref) -> ok | {error, term()}
%%
Expand Down
63 changes: 3 additions & 60 deletions apps/graphdb/src/graphdb_instance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -1201,8 +1201,8 @@ do_add_relationship(SourceNref, CharNref, TargetNref, ReciprocalNref,
{SourceClass, TargetClass} =
resolve_arc_classes_in_txn(SourceNref, TargetNref),
TemplateNref = resolve_template_in_txn(TemplateSpec, SourceClass),
ok = validate_template_scope_in_txn(TemplateNref, SourceClass,
TargetClass),
ok = graphdb_class:validate_template_scope_in_txn(TemplateNref,
SourceClass, TargetClass),
Rows = build_connection_rows(IdPair, SourceNref, CharNref, TargetNref,
ReciprocalNref, TemplateNref, AVPSpec),
lists:foreach(fun({Tab, Rec}) -> ok = mnesia:write(Tab, Rec, write) end,
Expand Down Expand Up @@ -1320,33 +1320,6 @@ resolve_template_in_txn(TemplateNref, _SourceClass)
TemplateNref.


%%-----------------------------------------------------------------------------
%% validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) -> ok
%% (aborts invalid_template / template_class_not_in_ancestry)
%%
%% Confirms TemplateNref resolves to a kind=template node whose parent class is
%% in SourceClass's or TargetClass's taxonomic ancestry. The nested Reason in
%% {invalid_template, _, Reason} is byte-identical to the gen-server get_template
%% form: get_template_in_txn returns the same {error, not_a_template|not_found}.
%%-----------------------------------------------------------------------------
validate_template_scope_in_txn(TemplateNref, SourceClass, TargetClass) ->
case graphdb_class:get_template_in_txn(TemplateNref) of
{ok, #node{parents = TmplParents}} ->
TmplClass = head_parent(TmplParents),
InSource = graphdb_class:class_in_ancestry_in_txn(TmplClass,
SourceClass),
InTarget = graphdb_class:class_in_ancestry_in_txn(TmplClass,
TargetClass),
case InSource orelse InTarget of
true -> ok;
false -> mnesia:abort({template_class_not_in_ancestry,
TemplateNref, TmplClass, SourceClass, TargetClass})
end;
{error, Reason} ->
mnesia:abort({invalid_template, TemplateNref, Reason})
end.


%%-----------------------------------------------------------------------------
%% build_connection_rows(S, C, T, R, TemplateNref, {FwdAVPs, RevAVPs})
%% -> [{relationships, #relationship{}}]
Expand Down Expand Up @@ -1668,7 +1641,7 @@ resolve_from_class(InstNref, AttrNref) ->
collect_class_hits(Classes, AttrNref) ->
lists:foldr(
fun(ClassNref, Acc) ->
case search_class_taxonomy(ClassNref, AttrNref) of
case graphdb_class:search_class_taxonomy(ClassNref, AttrNref) of
{ok, FoundClass, Value} -> [{FoundClass, Value} | Acc];
not_found -> Acc
end
Expand All @@ -1684,36 +1657,6 @@ classify_class_hits([{ClassNref, _} | _] = Hits, AttrNref) ->
_ -> {error, {ambiguous_class_value, AttrNref, Hits}}
end.

%% Walks ClassNref and its taxonomy ancestors (nearest-first), returning
%% the first AVP match together with the class nref where it was found.
search_class_taxonomy(ClassNref, AttrNref) ->
case graphdb_class:get_class(ClassNref) of
{ok, #node{attribute_value_pairs = AVPs}} ->
case find_avp_value(AVPs, AttrNref) of
{ok, V} ->
{ok, ClassNref, V};
not_found ->
case graphdb_class:ancestors(ClassNref) of
{ok, Ancestors} ->
search_first_in_ancestors(Ancestors, AttrNref);
_ ->
not_found
end
end;
_ ->
not_found
end.

search_first_in_ancestors([], _AttrNref) ->
not_found;
search_first_in_ancestors(
[#node{nref = N, attribute_value_pairs = AVPs} | Rest], AttrNref) ->
case find_avp_value(AVPs, AttrNref) of
{ok, V} -> {ok, N, V};
not_found -> search_first_in_ancestors(Rest, AttrNref)
end.


%%-----------------------------------------------------------------------------
%% resolve_from_ancestors(ParentNref, AttrNref) ->
%% {ok, Value, AncestorNref} | not_found | {error, term()}
Expand Down
91 changes: 90 additions & 1 deletion apps/graphdb/test/graphdb_class_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@
class_in_ancestry_in_txn_ancestor/1,
class_in_ancestry_in_txn_unrelated/1,
class_in_ancestry_in_txn_diamond/1,
validate_template_scope_in_txn_in_scope/1,
validate_template_scope_in_txn_rejects_non_template/1,
validate_template_scope_in_txn_rejects_out_of_ancestry/1,
search_class_taxonomy_on_class/1,
search_class_taxonomy_on_ancestor/1,
search_class_taxonomy_not_found/1,
%% Qualifying characteristics
add_qc_basic/1,
add_qc_idempotent/1,
Expand Down Expand Up @@ -174,7 +180,13 @@ groups() ->
class_in_ancestry_in_txn_self,
class_in_ancestry_in_txn_ancestor,
class_in_ancestry_in_txn_unrelated,
class_in_ancestry_in_txn_diamond
class_in_ancestry_in_txn_diamond,
validate_template_scope_in_txn_in_scope,
validate_template_scope_in_txn_rejects_non_template,
validate_template_scope_in_txn_rejects_out_of_ancestry,
search_class_taxonomy_on_class,
search_class_taxonomy_on_ancestor,
search_class_taxonomy_not_found
]},
{qualifying, [], [
add_qc_basic,
Expand Down Expand Up @@ -701,6 +713,83 @@ class_in_ancestry_in_txn_diamond(_Config) ->
graphdb_class:class_in_ancestry_in_txn(A, D)
end)).

%%-----------------------------------------------------------------------------
%% validate_template_scope_in_txn accepts a template whose parent class is in
%% the source class's ancestry (success returns ok -> {ok, ok}).
%%-----------------------------------------------------------------------------
validate_template_scope_in_txn_in_scope(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Person", 3),
{ok, Tmpl} = graphdb_class:default_template(ClassNref),
?assertEqual({ok, ok}, graphdb_mgr:transaction(fun() ->
graphdb_class:validate_template_scope_in_txn(Tmpl, ClassNref, ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% validate_template_scope_in_txn rejects an nref that is not a template node,
%% aborting {invalid_template, Nref, not_a_template}.
%%-----------------------------------------------------------------------------
validate_template_scope_in_txn_rejects_non_template(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
?assertEqual({error, {invalid_template, ClassNref, not_a_template}},
graphdb_mgr:transaction(fun() ->
graphdb_class:validate_template_scope_in_txn(ClassNref, ClassNref,
ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% validate_template_scope_in_txn rejects a template whose parent class is in
%% neither the source nor the target class's ancestry.
%%-----------------------------------------------------------------------------
validate_template_scope_in_txn_rejects_out_of_ancestry(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, AnimalCls} = graphdb_class:create_class("Animal", 3),
{ok, VehicleCls} = graphdb_class:create_class("Vehicle", 3),
{ok, VehTmpl} = graphdb_class:default_template(VehicleCls),
?assertEqual({error, {template_class_not_in_ancestry, VehTmpl, VehicleCls,
AnimalCls, AnimalCls}},
graphdb_mgr:transaction(fun() ->
graphdb_class:validate_template_scope_in_txn(VehTmpl, AnimalCls,
AnimalCls)
end)).

%%-----------------------------------------------------------------------------
%% search_class_taxonomy finds a value bound directly on the class node.
%%-----------------------------------------------------------------------------
search_class_taxonomy_on_class(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Color", 3),
ok = graphdb_class:add_qualifying_characteristic(ClassNref,
?NAME_ATTR_ATTRIBUTE),
ok = graphdb_class:bind_qc_value(ClassNref, ?NAME_ATTR_ATTRIBUTE, "blue"),
?assertEqual({ok, ClassNref, "blue"},
graphdb_class:search_class_taxonomy(ClassNref, ?NAME_ATTR_ATTRIBUTE)).

%%-----------------------------------------------------------------------------
%% search_class_taxonomy finds a value bound on a taxonomy ancestor when the
%% class itself does not carry it; reports the ancestor where it was found.
%%-----------------------------------------------------------------------------
search_class_taxonomy_on_ancestor(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, Parent} = graphdb_class:create_class("Animal", 3),
{ok, Child} = graphdb_class:create_class("Mammal", Parent),
ok = graphdb_class:add_qualifying_characteristic(Parent,
?NAME_ATTR_ATTRIBUTE),
ok = graphdb_class:bind_qc_value(Parent, ?NAME_ATTR_ATTRIBUTE, "warm"),
?assertEqual({ok, Parent, "warm"},
graphdb_class:search_class_taxonomy(Child, ?NAME_ATTR_ATTRIBUTE)).

%%-----------------------------------------------------------------------------
%% search_class_taxonomy returns not_found when neither the class nor any
%% ancestor binds the attribute.
%%-----------------------------------------------------------------------------
search_class_taxonomy_not_found(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Color", 3),
?assertEqual(not_found,
graphdb_class:search_class_taxonomy(ClassNref, ?NAME_ATTR_ATTRIBUTE)).


%%=============================================================================
%% Qualifying Characteristic Tests
Expand Down
Loading