diff --git a/.iex.exs b/.iex.exs index 67b7d57..3c20795 100644 --- a/.iex.exs +++ b/.iex.exs @@ -20,6 +20,7 @@ alias RDF.{ Dataset, PrefixMap, + PropertyMap } alias RDF.BlankNode, as: BNode diff --git a/lib/rdf.ex b/lib/rdf.ex index 492f7a6..46b0f8f 100644 --- a/lib/rdf.ex +++ b/lib/rdf.ex @@ -252,6 +252,7 @@ defmodule RDF do def list(native_list, opts), do: RDF.List.from(native_list, opts) defdelegate prefix_map(prefixes), to: RDF.PrefixMap, as: :new + defdelegate property_map(property_map), to: RDF.PropertyMap, as: :new defdelegate langString(value, opts), to: RDF.LangString, as: :new defdelegate lang_string(value, opts), to: RDF.LangString, as: :new diff --git a/lib/rdf/property_map.ex b/lib/rdf/property_map.ex new file mode 100644 index 0000000..55176cd --- /dev/null +++ b/lib/rdf/property_map.ex @@ -0,0 +1,143 @@ +defmodule RDF.PropertyMap do + defstruct iris: %{}, + terms: %{} + + alias RDF.IRI + + @type t :: %__MODULE__{ + iris: %{atom => IRI.t()}, + terms: %{IRI.t() => atom} + } + + @behaviour Access + + def new(), do: %__MODULE__{} + + def new(initial) do + {:ok, property_map} = new() |> add(initial) + + property_map + end + + def iri(%__MODULE__{} = property_map, term) do + Map.get(property_map.iris, coerce_term(term)) + end + + def term(%__MODULE__{} = property_map, iri) do + Map.get(property_map.terms, IRI.new(iri)) + end + + def iri_defined?(%__MODULE__{} = property_map, term) do + Map.has_key?(property_map.iris, coerce_term(term)) + end + + def term_defined?(%__MODULE__{} = property_map, iri) do + Map.has_key?(property_map.terms, IRI.new(iri)) + end + + @impl Access + def fetch(%__MODULE__{} = property_map, term) do + Access.fetch(property_map.iris, coerce_term(term)) + end + + def add(%__MODULE__{} = property_map, term, iri) do + do_set(property_map, :add, coerce_term(term), IRI.new(iri)) + end + + def add(%__MODULE__{} = property_map, mappings) do + Enum.reduce_while(mappings, {:ok, property_map}, fn {term, iri}, {:ok, property_map} -> + with {:ok, property_map} <- add(property_map, term, iri) do + {:cont, {:ok, property_map}} + else + error -> {:halt, error} + end + end) + end + + def put(%__MODULE__{} = property_map, term, iri) do + {:ok, added} = do_set(property_map, :put, coerce_term(term), IRI.new(iri)) + added + end + + def put(%__MODULE__{} = property_map, mappings) do + Enum.reduce(mappings, property_map, fn {term, iri}, property_map -> + put(property_map, term, iri) + end) + end + + defp do_set(property_map, op, term, iri) do + do_set(property_map, op, term, iri, Map.get(property_map.iris, term)) + end + + defp do_set(property_map, _, _, iri, iri), do: {:ok, property_map} + + defp do_set(property_map, _, term, iri, nil) do + {:ok, + %__MODULE__{ + property_map + | iris: Map.put(property_map.iris, term, iri), + terms: Map.put(property_map.terms, iri, term) + }} + end + + defp do_set(_context, :add, term, new_iri, old_iri) do + {:error, "conflicting mapping for #{term}: #{new_iri}; already mapped to #{old_iri}"} + end + + defp do_set(property_map, :put, term, new_iri, old_iri) do + %__MODULE__{property_map | terms: Map.delete(property_map.terms, old_iri)} + |> do_set(:put, term, new_iri, nil) + end + + def delete(%__MODULE__{} = property_map, term) do + term = coerce_term(term) + + if iri = Map.get(property_map.iris, term) do + %__MODULE__{ + property_map + | iris: Map.delete(property_map.iris, term), + terms: Map.delete(property_map.terms, iri) + } + else + property_map + end + end + + def drop(%__MODULE__{} = property_map, terms) when is_list(terms) do + Enum.reduce(terms, property_map, fn term, property_map -> + delete(property_map, term) + end) + end + + defp coerce_term(term) when is_atom(term), do: term + defp coerce_term(term) when is_binary(term), do: String.to_atom(term) + + @impl Access + def pop(%__MODULE__{} = property_map, term) do + case Access.pop(property_map.iris, coerce_term(term)) do + {nil, _} -> + {nil, property_map} + + {iri, new_context_map} -> + {iri, %__MODULE__{iris: new_context_map}} + end + end + + @impl Access + def get_and_update(property_map, term, fun) do + term = coerce_term(term) + current = iri(property_map, term) + + case fun.(current) do + {old_iri, new_iri} -> + {:ok, property_map} = do_set(property_map, :put, term, IRI.new(new_iri), IRI.new(old_iri)) + {old_iri, property_map} + + :pop -> + {current, delete(property_map, term)} + + other -> + raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}" + end + end +end diff --git a/test/unit/property_map_test.exs b/test/unit/property_map_test.exs new file mode 100644 index 0000000..ae28b31 --- /dev/null +++ b/test/unit/property_map_test.exs @@ -0,0 +1,200 @@ +defmodule RDF.PropertyMapTest do + use RDF.Test.Case + + doctest RDF.PropertyMap + + alias RDF.PropertyMap + + @example_property_map %PropertyMap{ + iris: %{ + foo: ~I, + bar: ~I, + Baz: RDF.iri(EX.Baz) + }, + terms: %{ + ~I => :foo, + ~I => :bar, + RDF.iri(EX.Baz) => :Baz + } + } + + test "new/1" do + assert PropertyMap.new( + foo: ~I, + bar: "http://example.com/test/bar", + Baz: EX.Baz + ) == @example_property_map + end + + describe "iri/2" do + test "when the given term exists" do + assert PropertyMap.iri(@example_property_map, "foo") == + ~I + + assert PropertyMap.iri(@example_property_map, :foo) == + ~I + end + + test "when the given term not exists" do + assert PropertyMap.iri(PropertyMap.new(), "foo") == nil + assert PropertyMap.iri(PropertyMap.new(), :foo) == nil + end + end + + describe "term/2" do + test "when the given IRI exists" do + assert PropertyMap.term(@example_property_map, ~I) == + :foo + + assert PropertyMap.term(@example_property_map, "http://example.com/test/foo") == + :foo + end + + test "when the given IRI not exists" do + assert PropertyMap.term(PropertyMap.new(), "http://example.com/test/foo") == + nil + + assert PropertyMap.term(PropertyMap.new(), ~I) == + nil + end + end + + describe "iri_defined?/2" do + test "when the given term exists" do + assert PropertyMap.iri_defined?(@example_property_map, "foo") == true + assert PropertyMap.iri_defined?(@example_property_map, :foo) == true + end + + test "when the given term not exists" do + assert PropertyMap.iri_defined?(PropertyMap.new(), "foo") == false + assert PropertyMap.iri_defined?(PropertyMap.new(), :foo) == false + end + end + + describe "term_defined?/2" do + test "when the given IRI exists" do + assert PropertyMap.term_defined?(@example_property_map, ~I) == + true + + assert PropertyMap.term_defined?(@example_property_map, "http://example.com/test/foo") == + true + end + + test "when the given IRI not exists" do + assert PropertyMap.term_defined?(PropertyMap.new(), "http://example.com/test/foo") == false + + assert PropertyMap.term_defined?(PropertyMap.new(), ~I) == + false + end + end + + describe "add/2" do + test "with valid mappings as keyword options" do + assert PropertyMap.add(PropertyMap.new(), + foo: ~I, + bar: "http://example.com/test/bar", + Baz: EX.Baz + ) == {:ok, @example_property_map} + end + + test "with valid mappings as a map" do + assert PropertyMap.add(PropertyMap.new(), %{ + :foo => ~I, + "bar" => "http://example.com/test/bar", + "Baz" => EX.Baz + }) == {:ok, @example_property_map} + end + + test "when a mapping to the same IRI exists" do + assert PropertyMap.add(@example_property_map, + foo: ~I, + bar: "http://example.com/test/bar", + Baz: EX.Baz + ) == {:ok, @example_property_map} + end + + test "when a mapping to another IRI exists" do + assert PropertyMap.add(@example_property_map, foo: ~I) == + {:error, + "conflicting mapping for foo: http://example.com/test/other; already mapped to http://example.com/test/foo"} + end + end + + describe "put/2" do + test "with valid mappings as keyword options" do + assert PropertyMap.put(PropertyMap.new(), + foo: ~I, + bar: "http://example.com/test/bar", + Baz: EX.Baz + ) == @example_property_map + end + + test "with valid mappings as a map" do + assert PropertyMap.put(PropertyMap.new(), %{ + :foo => ~I, + "bar" => "http://example.com/test/bar", + "Baz" => EX.Baz + }) == @example_property_map + end + + test "when mapping exists" do + assert PropertyMap.put(@example_property_map, + bar: "http://example.com/test/bar", + Baz: EX.qux(), + quux: EX.quux() + ) == + PropertyMap.new( + foo: ~I, + bar: ~I, + Baz: EX.qux(), + quux: EX.quux() + ) + end + end + + describe "delete/2" do + test "when a mapping for the given term exists" do + assert @example_property_map + |> PropertyMap.delete("foo") + |> PropertyMap.delete(:bar) == PropertyMap.new(Baz: EX.Baz) + end + + test "when a mapping for the given term not exists" do + assert @example_property_map + |> PropertyMap.delete("foobar") + |> PropertyMap.delete(:barfoo) == @example_property_map + end + end + + test "drop/2" do + assert PropertyMap.drop(@example_property_map, ["foo", :bar, :other]) == + PropertyMap.new(Baz: EX.Baz) + end + + describe "expand_description/2" do + end + + describe "Access behaviour" do + test "fetch/2" do + assert @example_property_map[:foo] == ~I + assert @example_property_map["foo"] == ~I + assert @example_property_map[:missing] == nil + assert @example_property_map["missing"] == nil + end + + test "get_and_update/2" do + update = fn current_value -> {current_value, to_string(current_value) <> "bar"} end + + assert Access.get_and_update(@example_property_map, :foo, &{&1, IRI.append(&1, "bar")}) == + {~I, + PropertyMap.put(@example_property_map, foo: ~I)} + + assert Access.get_and_update(@example_property_map, :foo, update) == + {~I, + PropertyMap.put(@example_property_map, :foo, ~I)} + + assert Access.get_and_update(@example_property_map, :foo, fn _ -> :pop end) == + {~I, PropertyMap.delete(@example_property_map, :foo)} + end + end +end