-module(graffeo_dets).
-moduledoc """
DETS-backed, on-disk, mutable handle backend.
The third graffeo backend — hand-rolled over three DETS tables
mirroring stdlib `digraph`'s ETS layout:
- `vtab` (set): `{V, Label}` — vertex to label.
- `etab` (set): `{{From, To}, Meta}` — edge keyed by ordered pair.
- `ntab` (bag): `{{out, V}, To}` / `{{in, V}, From}` — neighbour index.
Lifecycle:
- `new/0` — ephemeral graph under `data_dir/tmp/`; `delete/1` removes it.
- `open/1` — persistent named graph under `data_dir/`; open-or-create.
- `close/1` — flush and close, keeping the files (persistence).
- `delete/1` — close and remove the files.
The full read-half (`graffeo_backend`) is implemented, so every
`graffeo:*` algorithm works unchanged. Constructive ops
(`subgraph`, `condensation`, `filter_edges`, `contract`) are not
supported over DETS — use `graffeo:copy/2` to persist an in-memory
result to disk.
""".
-behaviour(graffeo_backend).
-behaviour(graffeo_builder).
-export([
new/0,
open/1,
close/1,
delete/1,
from_graph/2
]).
-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
]).
-export([
vertices/1,
out_neighbours/2,
in_neighbours/2,
in_degree/2,
out_degree/2,
no_edges/1,
no_vertices/1,
edge_meta/3,
vertex_label/2
]).
-export([
empty_like/1,
build_add_vertex/2, build_add_vertex/3,
build_add_edge/4
]).
-record(dets_ref, {
vtab :: dets:tab_name(),
etab :: dets:tab_name(),
ntab :: dets:tab_name(),
dir :: file:filename_all()
}).
-doc "Opaque reference to the DETS-backed graph internals.".
-opaque ref() :: #dets_ref{}.
-export_type([ref/0]).
%%% === Lifecycle ===
-doc "Create a new ephemeral DETS graph under `data_dir/tmp/`.".
-spec new() -> graffeo:graph().
new() ->
TmpDir = filename:join(graffeo_config:data_dir(), "tmp"),
Name = integer_to_list(erlang:unique_integer([positive])),
Dir = filename:join(TmpDir, Name),
open_tables(Dir).
-doc "Open or create a persistent named DETS graph under `data_dir/`.".
-spec open(string() | binary()) -> graffeo:graph().
open(Name) when is_binary(Name) ->
open(binary_to_list(Name));
open(Name) when is_list(Name) ->
Dir = filename:join(graffeo_config:data_dir(), Name),
open_tables(Dir).
-doc "Flush and close the DETS tables, keeping the files on disk.".
-spec close(graffeo:graph()) -> ok.
close(G) ->
Ref = require_handle(close, G),
close_tables(Ref).
-doc "Close and remove the DETS files from disk.".
-spec delete(graffeo:graph()) -> ok.
delete(G) ->
Ref = require_handle(delete, G),
close_tables(Ref),
del_dir(Ref#dets_ref.dir),
ok.
-doc """
Build a DETS graph from any graffeo graph (convenience for `copy/2`).
Opens a persistent DETS graph at `Name` and copies all vertices and
edges from `Src` into it via `graffeo:copy/2`.
""".
-spec from_graph(graffeo:graph(), string() | binary()) -> graffeo:graph().
from_graph(Src, Name) ->
Dst = open(Name),
graffeo:copy(Src, Dst).
%%% === Mutation ===
-doc "Add a vertex with the default label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex()) -> ok.
add_vertex(G, V) ->
add_vertex(G, V, undefined).
-doc "Add a vertex with a label.".
-spec add_vertex(graffeo:graph(), graffeo:vertex(), graffeo:label()) -> ok.
add_vertex(G, V, Label) ->
Ref = require_handle(add_vertex, G),
ok = dets:insert(Ref#dets_ref.vtab, {V, Label}),
ok.
-doc "Add an edge with default metadata.".
-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 (last-writer-wins).".
-spec add_edge(
graffeo:graph(),
graffeo:vertex(),
graffeo:vertex(),
graffeo:edge_meta()
) -> ok.
add_edge(G, From, To, Meta) ->
Ref = require_handle(add_edge, G),
ensure_vertex(Ref, From),
ensure_vertex(Ref, To),
case dets:lookup(Ref#dets_ref.etab, {From, To}) of
[] ->
ok = dets:insert(Ref#dets_ref.ntab, {{out, From}, To}),
ok = dets:insert(Ref#dets_ref.ntab, {{in, To}, From});
_ ->
ok
end,
ok = dets:insert(Ref#dets_ref.etab, {{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) ->
Ref = require_handle(del_vertex, G),
OutNbrs = out_neighbours_raw(Ref, V),
InNbrs = in_neighbours_raw(Ref, V),
lists:foreach(fun(To) -> del_edge_raw(Ref, V, To) end, OutNbrs),
lists:foreach(fun(From) -> del_edge_raw(Ref, From, V) end, InNbrs),
ok = dets:delete(Ref#dets_ref.vtab, V),
ok.
-doc "Delete a list of vertices and their incident edges.".
-spec del_vertices(graffeo:graph(), [graffeo:vertex()]) -> ok.
del_vertices(G, Vs) ->
lists:foreach(fun(V) -> del_vertex(G, V) end, Vs),
ok.
-doc "Remove the `From→To` edge.".
-spec del_edge(graffeo:graph(), graffeo:vertex(), graffeo:vertex()) -> ok.
del_edge(G, From, To) ->
Ref = require_handle(del_edge, G),
del_edge_raw(Ref, From, To),
ok.
-doc "Delete a list of `{From, To}` edges.".
-spec del_edges(graffeo:graph(), [{graffeo:vertex(), graffeo:vertex()}]) -> ok.
del_edges(G, Pairs) ->
Ref = require_handle(del_edges, G),
lists:foreach(fun({From, To}) -> del_edge_raw(Ref, From, To) end, Pairs),
ok.
%%% === graffeo_backend callbacks ===
-doc "All vertices in the graph.".
-spec vertices(ref()) -> [graffeo:vertex()].
vertices(Ref) ->
dets:foldl(fun({V, _Label}, Acc) -> [V | Acc] end, [], Ref#dets_ref.vtab).
-doc "Outgoing neighbours of `V`.".
-spec out_neighbours(ref(), graffeo:vertex()) -> [graffeo:vertex()].
out_neighbours(Ref, V) ->
out_neighbours_raw(Ref, V).
-doc "Incoming neighbours of `V`.".
-spec in_neighbours(ref(), graffeo:vertex()) -> [graffeo:vertex()].
in_neighbours(Ref, V) ->
in_neighbours_raw(Ref, V).
-doc "Number of incoming edges to `V`.".
-spec in_degree(ref(), graffeo:vertex()) -> non_neg_integer().
in_degree(Ref, V) ->
length(in_neighbours_raw(Ref, V)).
-doc "Number of outgoing edges from `V`.".
-spec out_degree(ref(), graffeo:vertex()) -> non_neg_integer().
out_degree(Ref, V) ->
length(out_neighbours_raw(Ref, V)).
-doc "Total number of edges.".
-spec no_edges(ref()) -> non_neg_integer().
no_edges(Ref) ->
dets:info(Ref#dets_ref.etab, no_objects).
-doc "Total number of vertices.".
-spec no_vertices(ref()) -> non_neg_integer().
no_vertices(Ref) ->
dets:info(Ref#dets_ref.vtab, no_objects).
-doc "Get edge metadata.".
-spec edge_meta(ref(), graffeo:vertex(), graffeo:vertex()) ->
{ok, graffeo:edge_meta()} | error.
edge_meta(Ref, From, To) ->
case dets:lookup(Ref#dets_ref.etab, {From, To}) of
[{{From, To}, Meta}] -> {ok, Meta};
[] -> error
end.
-doc "Get the label of a vertex.".
-spec vertex_label(ref(), graffeo:vertex()) ->
{ok, graffeo:label()} | error.
vertex_label(Ref, V) ->
case dets:lookup(Ref#dets_ref.vtab, V) of
[{V, Label}] -> {ok, Label};
[] -> error
end.
%%% === graffeo_builder callbacks ===
-doc false.
-spec empty_like(graffeo:graph()) -> no_return().
empty_like(_G) ->
erlang:error({unsupported_on_backend, empty_like, ?MODULE}).
-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 open_tables(file:filename_all()) -> graffeo:graph().
open_tables(Dir) ->
ok = filelib:ensure_dir(filename:join(Dir, "probe")),
VFile = filename:join(Dir, "vtab.dets"),
EFile = filename:join(Dir, "etab.dets"),
NFile = filename:join(Dir, "ntab.dets"),
VName = dets_name(VFile),
EName = dets_name(EFile),
NName = dets_name(NFile),
{ok, VName} = dets:open_file(VName, [{file, VFile}, {type, set}]),
{ok, EName} = dets:open_file(EName, [{file, EFile}, {type, set}]),
{ok, NName} = dets:open_file(NName, [{file, NFile}, {type, bag}]),
Ref = #dets_ref{vtab = VName, etab = EName, ntab = NName, dir = Dir},
graffeo:wrap_ref(?MODULE, Ref).
-spec dets_name(file:filename_all()) -> atom().
dets_name(Path) ->
list_to_atom("graffeo_dets_" ++ integer_to_list(erlang:phash2(Path))).
-spec close_tables(ref()) -> ok.
close_tables(Ref) ->
ok = dets:close(Ref#dets_ref.vtab),
ok = dets:close(Ref#dets_ref.etab),
ok = dets:close(Ref#dets_ref.ntab),
ok.
-spec del_dir(file:filename_all()) -> ok.
del_dir(Dir) ->
case file:list_dir(Dir) of
{ok, Files} ->
lists:foreach(
fun(F) -> file:delete(filename:join(Dir, F)) end, Files
),
_ = file:del_dir(Dir),
ok;
{error, _} ->
ok
end.
-spec require_handle(atom(), graffeo:graph()) -> ref().
require_handle(Op, G) ->
case graffeo:backend(G) of
?MODULE -> graffeo:extract_ref(?MODULE, G);
Other -> erlang:error({handle_only, Op, Other})
end.
-spec ensure_vertex(ref(), graffeo:vertex()) -> ok.
ensure_vertex(Ref, V) ->
case dets:lookup(Ref#dets_ref.vtab, V) of
[] -> ok = dets:insert(Ref#dets_ref.vtab, {V, undefined});
_ -> ok
end.
-spec out_neighbours_raw(ref(), graffeo:vertex()) -> [graffeo:vertex()].
out_neighbours_raw(Ref, V) ->
[To || {{out, _}, To} <- dets:lookup(Ref#dets_ref.ntab, {out, V})].
-spec in_neighbours_raw(ref(), graffeo:vertex()) -> [graffeo:vertex()].
in_neighbours_raw(Ref, V) ->
[From || {{in, _}, From} <- dets:lookup(Ref#dets_ref.ntab, {in, V})].
-spec del_edge_raw(ref(), graffeo:vertex(), graffeo:vertex()) -> ok.
del_edge_raw(Ref, From, To) ->
ok = dets:delete(Ref#dets_ref.etab, {From, To}),
ok = dets:delete_object(Ref#dets_ref.ntab, {{out, From}, To}),
ok = dets:delete_object(Ref#dets_ref.ntab, {{in, To}, From}),
ok.