diff --git a/lib/rdf/blank_node/generator.ex b/lib/rdf/blank_node/generator.ex new file mode 100644 index 0000000..9c4e6aa --- /dev/null +++ b/lib/rdf/blank_node/generator.ex @@ -0,0 +1,87 @@ +defmodule RDF.BlankNode.Generator do + @moduledoc """ + A GenServer generates `RDF.BlankNode`s using a `RDF.BlankNode.Generator.Algorithm`. + """ + + use GenServer + + + # Client API ############################################################### + + @doc """ + Starts a blank node generator linked to the current process. + + The state will be initialized according to the given `RDF.BlankNode.Generator.Algorithm`. + """ + def start_link(generation_mod, init_opts \\ %{}) do + GenServer.start_link(__MODULE__, {generation_mod, convert_opts(init_opts)}) + end + + @doc """ + Starts a blank node generator process without links (outside of a supervision tree). + + The state will be initialized according to the given `RDF.BlankNode.Generator.Algorithm`. + """ + def start(generation_mod, init_opts \\ %{}) do + GenServer.start(__MODULE__, {generation_mod, convert_opts(init_opts)}) + end + + defp convert_opts(nil), do: %{} + defp convert_opts(opts) when is_list(opts), do: Map.new(opts) + defp convert_opts(opts) when is_map(opts), do: opts + + + @doc """ + Synchronously stops the blank node generator with the given `reason`. + + It returns `:ok` if the agent terminates with the given reason. If the agent + terminates with another reason, the call will exit. + + This function keeps OTP semantics regarding error reporting. + If the reason is any other than `:normal`, `:shutdown` or `{:shutdown, _}`, an + error report will be logged. + """ + def stop(pid, reason \\ :normal, timeout \\ :infinity) do + GenServer.stop(pid, reason, timeout) + end + + + @doc """ + Generates a new blank node according to the `RDF.BlankNode.Generator.Algorithm` set up. + """ + def generate(pid) do + GenServer.call(pid, :generate) + end + + + @doc """ + Generates a blank node for a given string according to the `RDF.BlankNode.Generator.Algorithm` set up. + """ + def generate_for(pid, string) do + GenServer.call(pid, {:generate_for, string}) + end + + + # Server Callbacks ######################################################### + + @impl GenServer + def init({generation_mod, init_opts}) do + {:ok, {generation_mod, generation_mod.init(init_opts)}} + end + + + @impl GenServer + def handle_call(:generate, _from, {generation_mod, state}) do + with {bnode, new_state} = generation_mod.generate(state) do + {:reply, bnode, {generation_mod, new_state}} + end + end + + @impl GenServer + def handle_call({:generate_for, string}, _from, {generation_mod, state}) do + with {bnode, new_state} = generation_mod.generate_for(string, state) do + {:reply, bnode, {generation_mod, new_state}} + end + end + +end diff --git a/lib/rdf/blank_node/generator_algorithm.ex b/lib/rdf/blank_node/generator_algorithm.ex new file mode 100644 index 0000000..423b305 --- /dev/null +++ b/lib/rdf/blank_node/generator_algorithm.ex @@ -0,0 +1,32 @@ +defmodule RDF.BlankNode.Generator.Algorithm do + @moduledoc """ + A behaviour for implementations of blank node identifier generation algorithms. + + The `RDF.BlankNode.Generator` executes such an algorithm and holds its state. + """ + + @doc """ + Returns the initial state of the algorithm. + """ + @callback init(opts :: map | Keyword.t() | nil) :: map + + @doc """ + Generates a blank node. + + An implementation should compute a blank node from the given state and return + a tuple consisting of the generated blank node and the new state. + """ + @callback generate(state :: map) :: {RDF.BlankNode.t, map} + + @doc """ + Generates a blank node for a given string. + + Every call with the same string must return the same blank node. + + An implementation should compute a blank node for the given string from the + given state and return a tuple consisting of the generated blank node and the + new state. + """ + @callback generate_for(string :: binary, state :: map) :: {RDF.BlankNode.t, map} + +end diff --git a/lib/rdf/blank_node/increment.ex b/lib/rdf/blank_node/increment.ex new file mode 100644 index 0000000..c436f36 --- /dev/null +++ b/lib/rdf/blank_node/increment.ex @@ -0,0 +1,57 @@ +defmodule RDF.BlankNode.Increment do + @moduledoc """ + An implementation of a `RDF.BlankNode.Generator.Algorithm` which returns `RDF.BlankNode`s with incremented identifiers. + + The following options are supported when starting a `RDF.BlankNode.Generator` + with this algorithm: + + - `prefix`: a string prepended to the generated blank node identifier + - `start_value`: the number from which the incremented counter starts + + """ + + @behaviour RDF.BlankNode.Generator.Algorithm + + alias RDF.BlankNode + + @impl BlankNode.Generator.Algorithm + def init(%{prefix: prefix} = opts) do + opts + |> Map.delete(:prefix) + |> init() + |> Map.put(:prefix, prefix) + end + + @impl BlankNode.Generator.Algorithm + def init(opts) do + %{ + map: %{}, + counter: Map.get(opts, :start_value, 0) + } + end + + @impl BlankNode.Generator.Algorithm + def generate(%{counter: counter} = state) do + {bnode(counter, state), %{state | counter: counter + 1}} + end + + @impl BlankNode.Generator.Algorithm + def generate_for(string, %{map: map, counter: counter} = state) do + case Map.get(map, string) do + nil -> + {bnode(counter, state), + %{state | map: Map.put(map, string, counter), counter: counter + 1}} + previous -> + {bnode(previous, state), state} + end + end + + defp bnode(counter, %{prefix: prefix}) do + BlankNode.new(prefix <> Integer.to_string(counter)) + end + + defp bnode(counter, _) do + BlankNode.new(counter) + end + +end diff --git a/test/unit/blank_node/increment_test.exs b/test/unit/blank_node/increment_test.exs new file mode 100644 index 0000000..1b82e97 --- /dev/null +++ b/test/unit/blank_node/increment_test.exs @@ -0,0 +1,68 @@ +defmodule RDF.BlankNode.IncrementTest do + use RDF.Test.Case + + import RDF, only: [bnode: 1] + + alias RDF.BlankNode.Generator + alias RDF.BlankNode.Increment + + + describe "generate/1" do + test "without prefix" do + assert Increment.generate(%{counter: 0, map: %{}}) == + {bnode(0), (%{counter: 1, map: %{}})} + end + + test "with prefix" do + assert Increment.generate(%{counter: 0, map: %{}, prefix: "b"}) == + {bnode("b0"), (%{counter: 1, map: %{}, prefix: "b"})} + end + end + + + describe "generate_for/2" do + test "when the given string not exists in the map" do + assert Increment.generate_for("bar", %{counter: 1, map: %{"foo" => 0}}) == + {bnode(1), (%{counter: 2, map: %{"foo" => 0, "bar" => 1}})} + end + + test "when the given string exists in the map" do + assert Increment.generate_for("foo", %{counter: 1, map: %{"foo" => 0}}) == + {bnode(0), (%{counter: 1, map: %{"foo" => 0}})} + end + + test "with prefix" do + assert Increment.generate_for("bar", %{counter: 1, map: %{"foo" => 0}, prefix: "b"}) == + {bnode("b1"), (%{counter: 2, map: %{"foo" => 0, "bar" => 1}, prefix: "b"})} + assert Increment.generate_for("foo", %{counter: 1, map: %{"foo" => 0}, prefix: "b"}) == + {bnode("b0"), (%{counter: 1, map: %{"foo" => 0}, prefix: "b"})} + end + end + + + test "generator without prefix" do + {:ok, generator} = Generator.start_link(Increment) + + assert Generator.generate(generator) == bnode(0) + assert Generator.generate(generator) == bnode(1) + assert Generator.generate_for(generator, "foo") == bnode(2) + assert Generator.generate(generator) == bnode(3) + assert Generator.generate_for(generator, "bar") == bnode(4) + assert Generator.generate(generator) == bnode(5) + assert Generator.generate_for(generator, "foo") == bnode(2) + assert Generator.generate(generator) == bnode(6) + end + + test "generator with prefix" do + {:ok, generator} = Generator.start_link(Increment, prefix: "b") + + assert Generator.generate(generator) == bnode("b0") + assert Generator.generate(generator) == bnode("b1") + assert Generator.generate_for(generator, "foo") == bnode("b2") + assert Generator.generate(generator) == bnode("b3") + assert Generator.generate_for(generator, "bar") == bnode("b4") + assert Generator.generate(generator) == bnode("b5") + assert Generator.generate_for(generator, "foo") == bnode("b2") + assert Generator.generate(generator) == bnode("b6") + end +end