-module(graffeo_ets).
-moduledoc """
ETS-backed, process-owned, mutable handle backend.
Implemented over the stdlib `digraph` module (which uses ETS
internally). `wrap/1` accepts a bare `digraph:graph()` handle and
`unwrap/1` returns it for raw `digraph:*` access.
Construction, mutation, and lifecycle operate on the opaque
`graffeo:graph()` envelope. Presents a **simple directed graph**
view: at most one edge per ordered `(From, To)` pair.
""".
-behaviour(graffeo_backend).
-behaviour(graffeo_builder).
%% Construction / lifecycle (envelope-based)
-export([
new/0,
wrap/1,
unwrap/1,
delete/1
]).
%% Mutation (envelope-based, mutate-in-place)
-export([
add_vertex/2, add_vertex/3,
add_edge/3, add_edge/4,
del_vertex/2,
del_vertices/2,
del_edge/3,
del_edges/2
]).
%% Read (graffeo_backend, bare-ref)
-export([
vertices/1,
out_neighbours/2,
in_neighbours/2,
in_degree/2,
out_degree/2,
no_edges/1,
no_vertices/1
]).
%% Extra accessors (bare-ref)
-export([
edge_meta/3,
vertex_label/2
]).
%% graffeo_builder callbacks (envelope-based)
-export([
empty_like/1,
build_add_vertex/2, build_add_vertex/3,
build_add_edge/4
]).
%%% === Construction / lifecycle ===
-doc "Create a new, empty digraph handle wrapped in a graffeo envelope.".
-spec new() -> graffeo:graph().
new() ->
Ref = digraph:new(),
graffeo:wrap_ref(?MODULE, Ref).
-doc "Lift a bare `digraph` handle into a graffeo envelope.".
-spec wrap(digraph:graph()) -> graffeo:graph().
wrap(Ref) ->
graffeo:wrap_ref(?MODULE, Ref).
-doc """
Hand back the bare `digraph` handle from the envelope.
The explicit escape hatch for users who need raw `digraph:*` access.
""".
-spec unwrap(graffeo:graph()) -> digraph:graph().
unwrap(G) ->
require_handle(unwrap, G).
-doc """
Free the underlying digraph handle (ETS tables).
Use-after-delete is undefined.
""".
-spec delete(graffeo:graph()) -> ok.
delete(G) ->
D = require_handle(delete, G),
digraph:delete(D),
ok.
%%% === Mutation (envelope-based) ===
-doc "Add a vertex with the default label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex()) -> ok.
add_vertex(G, V) ->
D = require_handle(add_vertex, G),
digraph:add_vertex(D, V),
ok.
-doc "Add a vertex with a label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex(), graffeo:label()) -> ok.
add_vertex(G, V, Label) ->
D = require_handle(add_vertex, G),
digraph:add_vertex(D, V, Label),
ok.
-doc """
Add an edge with default metadata.
If a `From→To` edge already exists, its metadata is replaced
(simple-graph contract: at most one edge per ordered pair).
""".
-spec add_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
add_edge(G, From, To) ->
add_edge(G, From, To, #{weight => 1}).
-doc """
Add an edge with metadata (stored as the edge label).
If a `From→To` edge already exists, it is replaced with the new
metadata (last-writer-wins, simple-graph contract).
""".
-spec add_edge(
graffeo:graph(), graffeo:vertex(), graffeo:vertex(), graffeo:edge_meta()
) -> ok.
add_edge(G, From, To, Meta) ->
D = require_handle(add_edge, G),
digraph:add_vertex(D, From),
digraph:add_vertex(D, To),
remove_edges(D, From, To),
digraph:add_edge(D, From, To, Meta),
ok.
-doc "Remove vertex `V` and all its incident edges.".
-spec del_vertex(graffeo:graph(), graffeo:vertex()) -> ok.
del_vertex(G, V) ->
D = require_handle(del_vertex, G),
digraph:del_vertex(D, V),
ok.
-doc """
Remove the `From→To` edge.
If the underlying digraph has parallel edges (e.g. from `wrap/1`),
all of them are removed (simple-graph contract).
""".
-spec del_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
del_edge(G, From, To) ->
D = require_handle(del_edge, G),
remove_edges(D, From, To),
ok.
-doc "Delete a list of vertices and their incident edges.".
-spec del_vertices(graffeo:graph(), [graffeo:vertex()]) -> ok.
del_vertices(G, Vs) ->
D = require_handle(del_vertices, G),
lists:foreach(fun(V) -> digraph:del_vertex(D, V) end, Vs),
ok.
-doc "Delete a list of `{From, To}` edges.".
-spec del_edges(graffeo:graph(), [{graffeo:vertex(), graffeo:vertex()}]) -> ok.
del_edges(G, Pairs) ->
D = require_handle(del_edges, G),
lists:foreach(fun({From, To}) -> remove_edges(D, From, To) end, Pairs),
ok.
%%% === graffeo_backend callbacks (bare-ref) ===
-doc "All vertices in the graph.".
-spec vertices(digraph:graph()) -> [graffeo:vertex()].
vertices(Ref) ->
digraph:vertices(Ref).
-doc "Vertices reachable from `V` via outgoing edges (deduplicated).".
-spec out_neighbours(digraph:graph(), graffeo:vertex()) -> [graffeo:vertex()].
out_neighbours(Ref, V) ->
lists:uniq(digraph:out_neighbours(Ref, V)).
-doc "Vertices that reach `V` via incoming edges (deduplicated).".
-spec in_neighbours(digraph:graph(), graffeo:vertex()) -> [graffeo:vertex()].
in_neighbours(Ref, V) ->
lists:uniq(digraph:in_neighbours(Ref, V)).
-doc "Number of distinct incoming neighbours of `V`.".
-spec in_degree(digraph:graph(), graffeo:vertex()) -> non_neg_integer().
in_degree(Ref, V) ->
length(in_neighbours(Ref, V)).
-doc "Number of distinct outgoing neighbours of `V`.".
-spec out_degree(digraph:graph(), graffeo:vertex()) -> non_neg_integer().
out_degree(Ref, V) ->
length(out_neighbours(Ref, V)).
-doc "Total number of distinct `(From, To)` edges in the graph.".
-spec no_edges(digraph:graph()) -> non_neg_integer().
no_edges(Ref) ->
distinct_edge_count(Ref).
-doc "Total number of vertices in the graph.".
-spec no_vertices(digraph:graph()) -> non_neg_integer().
no_vertices(Ref) ->
digraph:no_vertices(Ref).
%%% === Extra accessors (bare-ref) ===
-doc """
Get edge metadata between two vertices.
If parallel edges exist (only possible via `wrap/1` of an external
multigraph), returns the metadata of the edge with the highest edge
ID by Erlang term order. For stdlib `digraph` this is the
most-recently-added edge, matching the map backend's last-writer-wins.
The selection is deterministic and independent of traversal order.
""".
-spec edge_meta(digraph:graph(), graffeo:vertex(), graffeo:vertex()) ->
{ok, graffeo:edge_meta()} | error.
edge_meta(Ref, From, To) ->
Edges = digraph:out_edges(Ref, From),
find_max_edge_meta(Ref, Edges, To).
-doc "Get the label of a vertex.".
-spec vertex_label(digraph:graph(), graffeo:vertex()) ->
{ok, graffeo:label()} | error.
vertex_label(Ref, V) ->
case digraph:vertex(Ref, V) of
{V, Label} -> {ok, Label};
false -> error
end.
%%% === graffeo_builder callbacks (envelope-based) ===
-doc false.
-spec empty_like(graffeo:graph()) -> graffeo:graph().
empty_like(_G) ->
new().
-doc false.
-spec build_add_vertex(graffeo:graph(), graffeo:vertex()) -> graffeo:graph().
build_add_vertex(G, V) ->
ok = add_vertex(G, V),
G.
-doc false.
-spec build_add_vertex(graffeo:graph(), graffeo:vertex(), graffeo:label()) -> graffeo:graph().
build_add_vertex(G, V, Label) ->
ok = add_vertex(G, V, Label),
G.
-doc false.
-spec build_add_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex(), graffeo:edge_meta()) ->
graffeo:graph().
build_add_edge(G, From, To, Meta) ->
ok = add_edge(G, From, To, Meta),
G.
%%% --- Internal ---
-spec require_handle(atom(), graffeo:graph()) -> digraph:graph().
require_handle(Op, G) ->
case graffeo:backend(G) of
?MODULE -> graffeo:extract_ref(?MODULE, G);
Other -> erlang:error({handle_only, Op, Other})
end.
-spec remove_edges(digraph:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
remove_edges(Ref, From, To) ->
Edges = digraph:out_edges(Ref, From),
lists:foreach(
fun(E) ->
case digraph:edge(Ref, E) of
{E, From, To, _Meta} -> digraph:del_edge(Ref, E);
_ -> ok
end
end,
Edges
).
-spec find_max_edge_meta(digraph:graph(), [digraph:edge()], graffeo:vertex()) ->
{ok, graffeo:edge_meta()} | error.
find_max_edge_meta(Ref, Edges, To) ->
Matching = [
{E, Meta}
|| E <- Edges,
{E1, _From, To1, Meta} <- [digraph:edge(Ref, E)],
E1 =:= E,
To1 =:= To
],
case Matching of
[] ->
error;
_ ->
{_, Meta} = lists:max(Matching),
{ok, Meta}
end.
-spec distinct_edge_count(digraph:graph()) -> non_neg_integer().
distinct_edge_count(Ref) ->
Pairs = lists:usort([
{From, To}
|| E <- digraph:edges(Ref),
{E1, From, To, _} <- [digraph:edge(Ref, E)],
E1 =:= E
]),
length(Pairs).