Skip to main content

src/graffeo_dets.erl

-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.