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: 8 additions & 6 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,14 @@ Tracked follow-ups (not in the seam spec):
arms). Design `docs/designs/transaction-seam-retrofit-design.md`; plan
`docs/superpowers/plans/2026-06-20-transaction-seam-retrofit.md`.
- **Atomic `add_relationship`** — collapse its four separate transactions
(validate → resolve classes → resolve template → write) into one.
Blocked on `graphdb_class` exposing tier-1 in-transaction read
primitives: its reads (`default_template`, `get_template`,
`class_in_ancestry`) are `gen_server:call` today, which cannot run inside
an Mnesia transaction. Sequence with / before `mutate/1`, which wants
those primitives too.
(validate → resolve classes → resolve template → write) into one. The
prerequisite tier-1 `graphdb_class` read primitives
(`get_template_in_txn/1`, `class_in_ancestry_in_txn/2`,
`default_template_in_txn/1`) have landed (PR 1,
`docs/designs/atomic-add-relationship-primitives-design.md`). PR 2 swaps
`add_relationship` onto them, converts the `source_has_no_class` /
`target_has_no_class` arms to `mnesia:abort/1`, and allocates the rel-id pair
up-front. Sequence with / before `mutate/1`, which wants those primitives too.
- **Batch `mutate([Mutation])`** — the tier-3 entry point.

### Node deletion (slice A) — IMPLEMENTED
Expand Down
7 changes: 7 additions & 0 deletions apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ Manages the "is a" hierarchy of class nodes in the ontology.
- `add_qualifying_characteristic/2` (class_nref, attribute_nref)
- `is_instantiable/1` (class_nref) — `false` iff the class carries the `instantiable => false` marker
- `get_class/1`, `subclasses/1`, `ancestors/1`, `inherited_qcs/1`
- `get_template_in_txn/1`, `class_in_ancestry_in_txn/2`,
`default_template_in_txn/1` — tier-1 **in-transaction** read primitives
(bare-mnesia twins of `get_template`/`class_in_ancestry`/`default_template`);
must be called inside an Mnesia activity. They compose into a caller's single
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`.

### `graphdb_instance` — Instance & Compositional Hierarchy

Expand Down
88 changes: 88 additions & 0 deletions apps/graphdb/src/graphdb_class.erl
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,15 @@
subclasses/1,
ancestors/1,
get_template/1,
get_template_in_txn/1,
templates_for_class/1,
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_in_ancestry/2,
class_in_ancestry_in_txn/2,
%% Inheritance
inherited_qcs/1
]).
Expand Down Expand Up @@ -713,6 +716,26 @@ template_has_name(#node{attribute_value_pairs = AVPs}, Name) ->
(_) -> false
end, AVPs).

%%-----------------------------------------------------------------------------
%% default_template_in_txn(ClassNref) -> {ok, Nref} | not_found
%%
%% Tier-1 in-transaction twin of default_template/1. Assumes it runs inside an
%% active mnesia activity; reuses the bare-mnesia downward_children_by_arc/3 and
%% template_has_name/2. Returns not_found when ClassNref has no template named
%% ?DEFAULT_TEMPLATE_NAME (e.g. an abstract class).
%%-----------------------------------------------------------------------------
default_template_in_txn(ClassNref) ->
Children = downward_children_by_arc(ClassNref, ?ARC_CLS_CHILD, composition),
case lists:search(fun
(#node{kind = template} = N) ->
template_has_name(N, ?DEFAULT_TEMPLATE_NAME);
(_) ->
false
end, Children) of
{value, #node{nref = Nref}} -> {ok, Nref};
false -> not_found
end.


%%-----------------------------------------------------------------------------
%% do_get_template(Nref) ->
Expand All @@ -725,6 +748,21 @@ do_get_template(Nref) ->
[] -> {error, not_found}
end.

%%-----------------------------------------------------------------------------
%% get_template_in_txn(Nref) ->
%% {ok, #node{}} | {error, not_a_template | not_found}
%%
%% Tier-1 in-transaction twin of do_get_template/1. Assumes it runs inside an
%% active mnesia activity; uses a bare mnesia:read. See
%% docs/designs/atomic-add-relationship-primitives-design.md.
%%-----------------------------------------------------------------------------
get_template_in_txn(Nref) ->
case mnesia:read(nodes, Nref) of
[#node{kind = template} = Node] -> {ok, Node};
[_Other] -> {error, not_a_template};
[] -> {error, not_found}
end.


%%-----------------------------------------------------------------------------
%% do_templates_for_class(ClassNref) -> {ok, [#node{}]} | {error, term()}
Expand Down Expand Up @@ -766,6 +804,56 @@ do_class_in_ancestry(CandidateNref, ClassNref) ->
end.


%%-----------------------------------------------------------------------------
%% class_in_ancestry_in_txn(CandidateNref, ClassNref) -> boolean()
%%
%% Tier-1 in-transaction twin of do_class_in_ancestry/2. Assumes it runs
%% inside an active mnesia activity; walks the taxonomic ancestry with bare
%% mnesia:read. Returns false on any lookup error.
%%-----------------------------------------------------------------------------
class_in_ancestry_in_txn(CandidateNref, CandidateNref) ->
true;
class_in_ancestry_in_txn(CandidateNref, ClassNref) ->
case ancestors_in_txn(ClassNref) of
{ok, Ancestors} ->
lists:any(fun(#node{nref = N}) -> N =:= CandidateNref end, Ancestors);
_ ->
false
end.

%%-----------------------------------------------------------------------------
%% ancestors_in_txn(ClassNref) -> {ok, [#node{}]} | {error, term()}
%%
%% Tier-1 in-transaction twin of do_ancestors/1: BFS over the multi-parent
%% taxonomic DAG with bare mnesia:read, nearest-first, each ancestor once,
%% the Classes category (nref 3) filtered out.
%%-----------------------------------------------------------------------------
ancestors_in_txn(ClassNref) ->
case mnesia:read(nodes, ClassNref) of
[#node{kind = class, parents = Parents}] ->
Initial = [P || P <- Parents, P =/= ?NREF_CLASSES],
walk_ancestors_in_txn(Initial, sets:from_list(Initial), []);
[_] ->
{error, not_a_class};
[] ->
{error, not_found}
end.

walk_ancestors_in_txn([], _Visited, Acc) ->
{ok, lists:reverse(Acc)};
walk_ancestors_in_txn([Nref | Rest], Visited, Acc) ->
case mnesia:read(nodes, Nref) of
[#node{kind = class, parents = Parents} = Node] ->
New = [P || P <- Parents,
P =/= ?NREF_CLASSES,
not sets:is_element(P, Visited)],
NewVisited = lists:foldl(fun sets:add_element/2, Visited, New),
walk_ancestors_in_txn(Rest ++ New, NewVisited, [Node | Acc]);
_ ->
walk_ancestors_in_txn(Rest, Visited, Acc)
end.


%%-----------------------------------------------------------------------------
%% do_validate_parent(ParentNref) -> ok | {error, term()}
%%
Expand Down
143 changes: 142 additions & 1 deletion apps/graphdb/test/graphdb_class_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,22 @@
add_template_rejects_non_class/1,
get_template_returns_node/1,
get_template_rejects_non_template/1,
get_template_in_txn_returns_node/1,
get_template_in_txn_rejects_non_template/1,
get_template_in_txn_not_found/1,
templates_for_class_lists_all/1,
default_template_returns_default/1,
default_template_not_found_after_delete/1,
default_template_in_txn_returns_default/1,
default_template_in_txn_abstract_not_found/1,
default_template_in_txn_not_found_after_delete/1,
class_in_ancestry_self/1,
class_in_ancestry_ancestor/1,
class_in_ancestry_unrelated/1,
class_in_ancestry_in_txn_self/1,
class_in_ancestry_in_txn_ancestor/1,
class_in_ancestry_in_txn_unrelated/1,
class_in_ancestry_in_txn_diamond/1,
%% Qualifying characteristics
add_qc_basic/1,
add_qc_idempotent/1,
Expand Down Expand Up @@ -149,12 +159,22 @@ groups() ->
add_template_rejects_non_class,
get_template_returns_node,
get_template_rejects_non_template,
get_template_in_txn_returns_node,
get_template_in_txn_rejects_non_template,
get_template_in_txn_not_found,
templates_for_class_lists_all,
default_template_returns_default,
default_template_not_found_after_delete,
default_template_in_txn_returns_default,
default_template_in_txn_abstract_not_found,
default_template_in_txn_not_found_after_delete,
class_in_ancestry_self,
class_in_ancestry_ancestor,
class_in_ancestry_unrelated
class_in_ancestry_unrelated,
class_in_ancestry_in_txn_self,
class_in_ancestry_in_txn_ancestor,
class_in_ancestry_in_txn_unrelated,
class_in_ancestry_in_txn_diamond
]},
{qualifying, [], [
add_qc_basic,
Expand Down Expand Up @@ -493,6 +513,38 @@ get_template_rejects_non_template(_Config) ->
?assertEqual({error, not_a_template},
graphdb_class:get_template(ClassNref)).

%%-----------------------------------------------------------------------------
%% get_template_in_txn returns the template node (in-transaction twin).
%%-----------------------------------------------------------------------------
get_template_in_txn_returns_node(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
{ok, TmplNref} = graphdb_class:default_template(ClassNref),
{ok, {ok, Node}} = graphdb_mgr:transaction(fun() ->
graphdb_class:get_template_in_txn(TmplNref)
end),
?assertEqual(TmplNref, Node#node.nref),
?assertEqual(template, Node#node.kind).

%%-----------------------------------------------------------------------------
%% get_template_in_txn rejects a class nref (kind mismatch).
%%-----------------------------------------------------------------------------
get_template_in_txn_rejects_non_template(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
?assertEqual({ok, {error, not_a_template}}, graphdb_mgr:transaction(fun() ->
graphdb_class:get_template_in_txn(ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% get_template_in_txn returns not_found for an unused nref.
%%-----------------------------------------------------------------------------
get_template_in_txn_not_found(_Config) ->
{ok, _} = graphdb_class:start_link(),
?assertEqual({ok, {error, not_found}}, graphdb_mgr:transaction(fun() ->
graphdb_class:get_template_in_txn(999999)
end)).

%%-----------------------------------------------------------------------------
%% templates_for_class returns all templates (default plus any added).
%%-----------------------------------------------------------------------------
Expand Down Expand Up @@ -532,6 +584,45 @@ default_template_not_found_after_delete(_Config) ->
end),
?assertEqual(not_found, graphdb_class:default_template(ClassNref)).

%%-----------------------------------------------------------------------------
%% default_template_in_txn returns the default template nref (in-tx twin).
%%-----------------------------------------------------------------------------
default_template_in_txn_returns_default(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
{ok, Expected} = graphdb_class:default_template(ClassNref),
?assertEqual({ok, {ok, Expected}}, graphdb_mgr:transaction(fun() ->
graphdb_class:default_template_in_txn(ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% default_template_in_txn returns not_found for an abstract class (born
%% without a default template).
%%-----------------------------------------------------------------------------
default_template_in_txn_abstract_not_found(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, #{instantiable := Inst}} = graphdb_attr:seeded_nrefs(),
Marker = #{attribute => Inst, value => false},
{ok, ClassNref} = graphdb_class:create_class("Abstract", 3, [Marker]),
?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() ->
graphdb_class:default_template_in_txn(ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% default_template_in_txn returns not_found after the default template node
%% is deleted.
%%-----------------------------------------------------------------------------
default_template_in_txn_not_found_after_delete(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
{ok, TmplNref} = graphdb_class:default_template(ClassNref),
{atomic, ok} = mnesia:transaction(fun() ->
mnesia:delete({nodes, TmplNref})
end),
?assertEqual({ok, not_found}, graphdb_mgr:transaction(fun() ->
graphdb_class:default_template_in_txn(ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% class_in_ancestry returns true when the candidate equals the class.
%%-----------------------------------------------------------------------------
Expand Down Expand Up @@ -560,6 +651,56 @@ class_in_ancestry_unrelated(_Config) ->
{ok, VehicleNref} = graphdb_class:create_class("Vehicle", 3),
?assertNot(graphdb_class:class_in_ancestry(VehicleNref, AnimalNref)).

%%-----------------------------------------------------------------------------
%% class_in_ancestry_in_txn: self is in its own ancestry (in-transaction twin).
%%-----------------------------------------------------------------------------
class_in_ancestry_in_txn_self(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, ClassNref} = graphdb_class:create_class("Animal", 3),
?assertEqual({ok, true}, graphdb_mgr:transaction(fun() ->
graphdb_class:class_in_ancestry_in_txn(ClassNref, ClassNref)
end)).

%%-----------------------------------------------------------------------------
%% class_in_ancestry_in_txn: true for direct and transitive ancestors.
%%-----------------------------------------------------------------------------
class_in_ancestry_in_txn_ancestor(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, AnimalNref} = graphdb_class:create_class("Animal", 3),
{ok, MammalNref} = graphdb_class:create_class("Mammal", AnimalNref),
{ok, WhaleNref} = graphdb_class:create_class("Whale", MammalNref),
?assertEqual({ok, true}, graphdb_mgr:transaction(fun() ->
graphdb_class:class_in_ancestry_in_txn(AnimalNref, WhaleNref)
end)),
?assertEqual({ok, true}, graphdb_mgr:transaction(fun() ->
graphdb_class:class_in_ancestry_in_txn(MammalNref, WhaleNref)
end)).

%%-----------------------------------------------------------------------------
%% class_in_ancestry_in_txn: false for unrelated classes.
%%-----------------------------------------------------------------------------
class_in_ancestry_in_txn_unrelated(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, AnimalNref} = graphdb_class:create_class("Animal", 3),
{ok, VehicleNref} = graphdb_class:create_class("Vehicle", 3),
?assertEqual({ok, false}, graphdb_mgr:transaction(fun() ->
graphdb_class:class_in_ancestry_in_txn(VehicleNref, AnimalNref)
end)).

%%-----------------------------------------------------------------------------
%% class_in_ancestry_in_txn: true for a diamond ancestor reached via two paths.
%%-----------------------------------------------------------------------------
class_in_ancestry_in_txn_diamond(_Config) ->
{ok, _} = graphdb_class:start_link(),
{ok, A} = graphdb_class:create_class("A", 3),
{ok, B} = graphdb_class:create_class("B", A),
{ok, C} = graphdb_class:create_class("C", A),
{ok, D} = graphdb_class:create_class("D", B),
ok = graphdb_class:add_superclass(D, C),
?assertEqual({ok, true}, graphdb_mgr:transaction(fun() ->
graphdb_class:class_in_ancestry_in_txn(A, D)
end)).


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