diff --git a/apps/graphdb/CLAUDE.md b/apps/graphdb/CLAUDE.md index 94653c5..4583eee 100644 --- a/apps/graphdb/CLAUDE.md +++ b/apps/graphdb/CLAUDE.md @@ -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 diff --git a/apps/graphdb/src/graphdb_class.erl b/apps/graphdb/src/graphdb_class.erl index ce7ac62..6090855 100644 --- a/apps/graphdb/src/graphdb_class.erl +++ b/apps/graphdb/src/graphdb_class.erl @@ -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 ]). %%--------------------------------------------------------------------- @@ -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()} %% diff --git a/apps/graphdb/src/graphdb_instance.erl b/apps/graphdb/src/graphdb_instance.erl index c5174eb..13c0982 100644 --- a/apps/graphdb/src/graphdb_instance.erl +++ b/apps/graphdb/src/graphdb_instance.erl @@ -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, @@ -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{}}] @@ -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 @@ -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()} diff --git a/apps/graphdb/test/graphdb_class_SUITE.erl b/apps/graphdb/test/graphdb_class_SUITE.erl index 843bb24..874d22a 100644 --- a/apps/graphdb/test/graphdb_class_SUITE.erl +++ b/apps/graphdb/test/graphdb_class_SUITE.erl @@ -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, @@ -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, @@ -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