diff --git a/lib/rdf/blank_node_generator/random.ex b/lib/rdf/blank_node_generator/random.ex index dba3ec95..ca5d62cf 100644 --- a/lib/rdf/blank_node_generator/random.ex +++ b/lib/rdf/blank_node_generator/random.ex @@ -1,6 +1,11 @@ defmodule RDF.BlankNode.Generator.Random do @moduledoc """ An implementation of a `RDF.BlankNode.Generator.Algorithm` which returns `RDF.BlankNode`s with random identifiers. + + Note, although this generator is faster than the `RDF.BlankNode.Generator.UUID` generator, + which also produces random identifiers, the random identifiers produced by + `RDF.BlankNode.Generator.Random` are not unique across multiple application runs, + since they are based on numbers returned by `:erlang.unique_integer/1`. """ @behaviour RDF.BlankNode.Generator.Algorithm diff --git a/lib/rdf/blank_node_generator/uuid.ex b/lib/rdf/blank_node_generator/uuid.ex new file mode 100644 index 00000000..692acbd2 --- /dev/null +++ b/lib/rdf/blank_node_generator/uuid.ex @@ -0,0 +1,52 @@ +if Code.ensure_loaded?(UUID) do + defmodule RDF.BlankNode.Generator.UUID do + @moduledoc """ + An implementation of a `RDF.BlankNode.Generator.Algorithm` which returns `RDF.BlankNode`s with random UUID identifiers. + + This module is only available if the optional `:elixir_uuid` dependency is available. + """ + + @behaviour RDF.BlankNode.Generator.Algorithm + + defstruct prefix: "b" + + @type t :: %__MODULE__{prefix: String.t()} + + alias RDF.BlankNode + + @doc """ + Creates a struct with the state of the algorithm. + + ## Options + + - `prefix`: a string prepended to the generated blank node identifier + """ + def new(attrs \\ []) do + struct(__MODULE__, attrs) + end + + @impl BlankNode.Generator.Algorithm + def generate(%__MODULE__{} = state) do + {bnode(state, uuid()), state} + end + + @impl BlankNode.Generator.Algorithm + def generate_for(%__MODULE__{} = state, value) do + {bnode(state, uuid_for(value)), state} + end + + defp bnode(%__MODULE__{prefix: nil}, uuid), do: BlankNode.new(uuid) + defp bnode(%__MODULE__{} = state, uuid), do: BlankNode.new(state.prefix <> uuid) + + defp uuid, do: UUID.uuid4(:hex) + + # 74fdf771-1a01-5e8f-a491-1c9e61f1adc6 + @uuid_namespace UUID.uuid5(:url, "https://rdf-elixir.dev/blank-node-generator/uuid") + + defp uuid_for(value) when is_binary(value), + do: UUID.uuid5(@uuid_namespace, value, :hex) + + defp uuid_for(value), + do: value |> :erlang.term_to_binary() |> uuid_for() + end +end diff --git a/lib/rdf/resource_generator/generators/iri_uuid_generator.ex b/lib/rdf/resource_generator/generators/iri_uuid_generator.ex index 7a2b4e1f..83ace536 100644 --- a/lib/rdf/resource_generator/generators/iri_uuid_generator.ex +++ b/lib/rdf/resource_generator/generators/iri_uuid_generator.ex @@ -1,7 +1,3 @@ -# Since optional dependencies don't get started, dialyzer can't find these functions. -# We're ignoring these warnings (via .dialyzer_ignore). -# See https://elixirforum.com/t/confusing-behavior-of-optional-deps-in-mix-exs/17719/4 - if Code.ensure_loaded?(UUID) do defmodule RDF.IRI.UUID.Generator do @moduledoc """ diff --git a/lib/rdf/serializations/turtle_trig/decoder/state.ex b/lib/rdf/serializations/turtle_trig/decoder/state.ex index fada7bfc..b4c417cc 100644 --- a/lib/rdf/serializations/turtle_trig/decoder/state.ex +++ b/lib/rdf/serializations/turtle_trig/decoder/state.ex @@ -26,6 +26,12 @@ defmodule RDF.TurtleTriG.Decoder.State do if bnode_gen = default_bnode_gen(), do: bnode_generator(bnode_gen) end + if Code.ensure_loaded?(UUID) do + defp bnode_generator(:uuid), do: bnode_generator(BlankNode.Generator.UUID) + else + raise "elixir_uuid dependency not available" + end + defp bnode_generator(:random), do: bnode_generator(BlankNode.Generator.Random) defp bnode_generator(:increment), do: bnode_generator(BlankNode.Generator.Increment) defp bnode_generator(algorithm) when is_atom(algorithm), do: struct(algorithm) diff --git a/test/unit/blank_node_generator/generator_test.exs b/test/unit/blank_node_generator/generator_test.exs index 3d525d12..b1354164 100644 --- a/test/unit/blank_node_generator/generator_test.exs +++ b/test/unit/blank_node_generator/generator_test.exs @@ -4,7 +4,7 @@ defmodule RDF.BlankNode.GeneratorTest do import RDF, only: [bnode: 1] alias RDF.BlankNode.Generator - alias RDF.BlankNode.Generator.{Increment, Random} + alias RDF.BlankNode.Generator.{Increment, Random, UUID} describe "Increment generator" do test "generator without prefix" do @@ -92,4 +92,37 @@ defmodule RDF.BlankNode.GeneratorTest do assert Generator.generate_for(generator, {:foo, 42}) == bnode end end + + describe "UUID generator" do + test "generator without prefix" do + {:ok, generator} = start_supervised({Generator, UUID}) + + assert %RDF.BlankNode{} = bnode1 = Generator.generate(generator) + assert %RDF.BlankNode{} = bnode2 = Generator.generate(generator) + + assert bnode1 != bnode2 + + assert %RDF.BlankNode{} = bnode1 = Generator.generate_for(generator, "foo") + assert Generator.generate_for(generator, "foo") == bnode1 + assert %RDF.BlankNode{} = bnode2 = Generator.generate_for(generator, "bar") + + assert bnode1 != bnode2 + end + + test "generator with prefix" do + {:ok, generator} = start_supervised({Generator, UUID.new(prefix: "b")}) + + assert %RDF.BlankNode{value: "b" <> _} = bnode1 = Generator.generate(generator) + assert %RDF.BlankNode{value: "b" <> _} = bnode2 = Generator.generate(generator) + + assert bnode1 != bnode2 + end + + test "generator with non-string values" do + {:ok, generator} = start_supervised({Generator, UUID.new(prefix: "b")}) + + assert %RDF.BlankNode{} = bnode = Generator.generate_for(generator, {:foo, 42}) + assert Generator.generate_for(generator, {:foo, 42}) == bnode + end + end end diff --git a/test/unit/blank_node_generator/uuid_test.exs b/test/unit/blank_node_generator/uuid_test.exs new file mode 100644 index 00000000..6d27366f --- /dev/null +++ b/test/unit/blank_node_generator/uuid_test.exs @@ -0,0 +1,39 @@ +defmodule RDF.BlankNode.Generator.UUIDTest do + use RDF.Test.Case + + import RDF, only: [bnode: 1] + + alias RDF.BlankNode.Generator.UUID + + describe "generate/1" do + test "without prefix" do + assert {%BlankNode{}, %UUID{}} = UUID.generate(%UUID{}) + end + + test "with prefix" do + assert {%BlankNode{value: "b" <> _}, %UUID{prefix: "b"}} = + UUID.generate(%UUID{prefix: "b"}) + end + end + + describe "generate_for/2" do + test "returns the same id for the same value" do + assert {%BlankNode{value: "b12df7fcd25a15b2d84b9db6d881aefdc"}, %UUID{}} = + UUID.generate_for(%UUID{}, "foo") + + assert UUID.generate_for(%UUID{}, "foo") == + UUID.generate_for(%UUID{}, "foo") + + assert UUID.generate_for(%UUID{}, {:foo, "foo", ~U[2024-07-13 02:21:45.085932Z]}) == + UUID.generate_for(%UUID{}, {:foo, "foo", ~U[2024-07-13 02:21:45.085932Z]}) + end + + test "with prefix" do + assert {%BlankNode{value: "x03c964986571507facc25bcf9ae8e8a6"}, %UUID{prefix: "x"}} = + UUID.generate_for(%UUID{prefix: "x"}, "bar") + + assert UUID.generate_for(%UUID{prefix: "x"}, "foo") == + {bnode("x12df7fcd25a15b2d84b9db6d881aefdc"), %UUID{prefix: "x"}} + end + end +end diff --git a/test/unit/serializations/turtle_decoder_test.exs b/test/unit/serializations/turtle_decoder_test.exs index c34f4cf8..49f15d46 100644 --- a/test/unit/serializations/turtle_decoder_test.exs +++ b/test/unit/serializations/turtle_decoder_test.exs @@ -159,6 +159,19 @@ defmodule RDF.Turtle.DecoderTest do """, bnode_gen: BlankNode.Generator.Random ) + + assert Turtle.Decoder.decode!( + """ + [ 42 ] . + """, + bnode_gen: :uuid + ) != + Turtle.Decoder.decode!( + """ + [ 42 ] . + """, + bnode_gen: :uuid + ) end test "blank node property list on object position" do