diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fe08a..0a1cf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ This project adheres to [Semantic Versioning](http://semver.org/) and [Keep a CHANGELOG](http://keepachangelog.com). +## Unreleased + +### Added + +- `RDF.PrefixMap` + + +[Compare v0.5.4...HEAD](https://github.com/marcelotto/rdf-ex/compare/v0.5.4...HEAD) + + + ## 0.5.4 - 2019-01-17 ### Fixed diff --git a/lib/rdf.ex b/lib/rdf.ex index 6600a67..5338997 100644 --- a/lib/rdf.ex +++ b/lib/rdf.ex @@ -189,6 +189,8 @@ defmodule RDF do defdelegate datetime(value), to: RDF.DateTime, as: :new defdelegate datetime(value, opts), to: RDF.DateTime, as: :new + defdelegate prefix_map(prefixes), to: RDF.PrefixMap, as: :new + for term <- ~w[type subject predicate object first rest value]a do defdelegate unquote(term)(), to: RDF.NS.RDF defdelegate unquote(term)(s, o), to: RDF.NS.RDF diff --git a/lib/rdf/prefix_map.ex b/lib/rdf/prefix_map.ex new file mode 100644 index 0000000..ad1d30d --- /dev/null +++ b/lib/rdf/prefix_map.ex @@ -0,0 +1,197 @@ +defmodule RDF.PrefixMap do + @moduledoc """ + A mapping a prefix atoms to IRI namespaces. + + `RDF.PrefixMap` implements the `Enumerable` protocol. + """ + + defstruct map: %{} + + alias RDF.IRI + + @doc """ + Creates an empty `RDF.PrefixMap. + """ + def new(), do: %__MODULE__{} + + @doc """ + Creates a new `RDF.PrefixMap. + + The prefix mappings can be passed as keyword lists or maps. + The keys for the prefixes can be given as atoms or strings and will be normalized to atoms. + The namespaces can be given as `RDF.IRI`s or strings and will be normalized to `RDF.IRI`s. + """ + def new(map) + + def new(map) when is_map(map) do + %__MODULE__{map: Map.new(map, &normalize/1)} + end + + def new(map) when is_list(map) do + map |> Map.new() |> new() + end + + defp normalize({prefix, %IRI{} = namespace}) when is_atom(prefix), + do: {prefix, namespace} + + defp normalize({prefix, namespace}) when is_atom(prefix), + do: normalize({prefix, IRI.new(namespace)}) + + defp normalize({prefix, namespace}) when is_binary(prefix), + do: normalize({String.to_atom(prefix), namespace}) + + defp normalize({prefix, _}), + do: raise("Invalid prefix on PrefixMap: #{inspect(prefix)}}") + + @doc """ + Adds a prefix mapping the given `RDF.PrefixMap`. + + Unless a mapping of the given prefix to a different namespace already exists, + an ok tuple is returned, other an error tuple. + """ + def add(prefix_map, prefix, namespace) + + def add(%__MODULE__{map: map}, prefix, %IRI{} = namespace) when is_atom(prefix) do + if conflicts?(map, prefix, namespace) do + {:error, "prefix #{inspect(prefix)} is already mapped to another namespace"} + else + {:ok, %__MODULE__{map: Map.put(map, prefix, namespace)}} + end + end + + def add(%__MODULE__{} = prefix_map, prefix, namespace) do + with {prefix, namespace} = normalize({prefix, namespace}) do + add(prefix_map, prefix, namespace) + end + end + + @doc """ + Adds a prefix mapping to the given `RDF.PrefixMap` and raises an exception in error cases. + """ + def add!(prefix_map, prefix, namespace) do + with {:ok, new_prefix_map} <- add(prefix_map, prefix, namespace) do + new_prefix_map + else + {:error, error} -> raise error + end + end + + @doc """ + Merges two `RDF.PrefixMap`s. + + The second prefix map can also be given as any structure which can converted + to a `RDF.PrefixMap` via `new/1`. + + If there are conflicts between the prefix maps, that is prefixes mapped to + different namespaces and error tuple is returned, otherwise an ok tuple. + """ + def merge(prefix_map1, prefix_map2) + + def merge(%__MODULE__{map: map1}, %__MODULE__{map: map2}) do + with [] <- merge_conflicts(map1, map2) do + {:ok, %__MODULE__{map: Map.merge(map1, map2)}} + else + conflicts -> + {:error, "conflicting prefix mappings: #{conflicts |> Stream.map(&inspect/1) |> Enum.join(", ")}"} + end + end + + def merge(%__MODULE__{} = prefix_map, other_prefixes) do + merge(prefix_map, new(other_prefixes)) + rescue + FunctionClauseError -> + raise ArgumentError, "#{inspect(other_prefixes)} is not convertible to a RDF.PrefixMap" + end + + defp merge_conflicts(map1, map2) do + Enum.reduce(map1, [], fn {prefix, namespace}, conflicts -> + if conflicts?(map2, prefix, namespace) do + [prefix | conflicts] + else + conflicts + end + end) + end + + defp conflicts?(map, prefix, namespace) do + (existing_namespace = Map.get(map, prefix)) && existing_namespace != namespace + end + + @doc """ + Deletes a prefix mapping from the given `RDF.PrefixMap`.. + """ + def delete(prefix_map, prefix) + + def delete(%__MODULE__{map: map}, prefix) when is_atom(prefix) do + %__MODULE__{map: Map.delete(map, prefix)} + end + + def delete(prefix_map, prefix) when is_binary(prefix) do + delete(prefix_map, String.to_atom(prefix)) + end + + @doc """ + Returns the namespace for the given prefix in the given `RDF.PrefixMap`. + + Returns `nil`, when the given `prefix` is not present in `prefix_map`. + """ + def namespace(prefix_map, prefix) + + def namespace(%__MODULE__{map: map}, prefix) when is_atom(prefix) do + Map.get(map, prefix) + end + + def namespace(prefix_map, prefix) when is_binary(prefix) do + namespace(prefix_map, String.to_atom(prefix)) + end + + @doc """ + Returns the prefix for the given namespace in the given `RDF.PrefixMap`. + + Returns `nil`, when the given `namespace` is not present in `prefix_map`. + """ + def prefix(prefix_map, namespace) + + def prefix(%__MODULE__{map: map}, %IRI{} = namespace) do + Enum.find_value(map, fn {prefix, ns} -> ns == namespace && prefix end) + end + + def prefix(prefix_map, namespace) when is_binary(namespace) do + prefix(prefix_map, IRI.new(namespace)) + end + + @doc """ + Returns whether the given prefix exists in the given `RDF.PrefixMap`. + """ + def has_prefix?(prefix_map, prefix) + + def has_prefix?(%__MODULE__{map: map}, prefix) when is_atom(prefix) do + Map.has_key?(map, prefix) + end + + def has_prefix?(prefix_map, prefix) when is_binary(prefix) do + has_prefix?(prefix_map, String.to_atom(prefix)) + end + + @doc """ + Returns all prefixes from the given `RDF.PrefixMap`. + """ + def prefixes(%__MODULE__{map: map}) do + Map.keys(map) + end + + @doc """ + Returns all namespaces from the given `RDF.PrefixMap`. + """ + def namespaces(%__MODULE__{map: map}) do + Map.values(map) + end + + defimpl Enumerable do + def reduce(%RDF.PrefixMap{map: map}, acc, fun), do: Enumerable.reduce(map, acc, fun) + + def member?(%RDF.PrefixMap{map: map}, mapping), do: Enumerable.member?(map, mapping) + def count(%RDF.PrefixMap{map: map}), do: Enumerable.count(map) + def slice(_prefix_map), do: {:error, __MODULE__} + end +end diff --git a/test/unit/prefix_map_test.exs b/test/unit/prefix_map_test.exs new file mode 100644 index 0000000..872cab0 --- /dev/null +++ b/test/unit/prefix_map_test.exs @@ -0,0 +1,193 @@ +defmodule RDF.PrefixMapTest do + use RDF.Test.Case + + alias RDF.PrefixMap + + @ex_ns1 ~I + @ex_ns2 ~I + @ex_ns3 ~I + + @example1 %PrefixMap{map: %{ex1: @ex_ns1}} + + @example2 %PrefixMap{map: %{ + ex1: @ex_ns1, + ex2: @ex_ns2 + }} + + @example3 %PrefixMap{map: %{ + ex1: @ex_ns1, + ex2: @ex_ns2, + ex3: @ex_ns3 + }} + + test "new/0" do + assert PrefixMap.new() == %PrefixMap{} + end + + describe "new/1" do + test "with a map" do + assert PrefixMap.new(%{ + "ex1" => "http://example.com/foo/", + "ex2" => "http://example.com/bar#" + }) == @example2 + end + + test "with a keyword map" do + assert PrefixMap.new( + ex1: "http://example.com/foo/", + ex2: "http://example.com/bar#" + ) == @example2 + end + end + + describe "add/3" do + test "when no mapping of the given prefix exists" do + assert PrefixMap.add(@example1, :ex2, @ex_ns2) == {:ok, @example2} + end + + test "with the prefix is given as a string" do + assert PrefixMap.add(@example1, "ex2", @ex_ns2) == {:ok, @example2} + end + + test "with the IRI namespace is given as a string" do + assert PrefixMap.add(@example1, :ex2, "http://example.com/bar#") == {:ok, @example2} + end + + test "when a mapping of the given prefix to the same namespace already exists" do + assert PrefixMap.add(@example2, :ex2, "http://example.com/bar#") == {:ok, @example2} + end + + test "when a mapping of the given prefix to a different namespace already exists" do + assert PrefixMap.add(@example2, :ex2, @ex_ns3) == + {:error, "prefix :ex2 is already mapped to another namespace"} + end + end + + describe "add!/3" do + test "when no mapping of the given prefix exists" do + assert PrefixMap.add!(@example1, :ex2, @ex_ns2) == @example2 + end + + test "when a mapping of the given prefix to a different namespace already exists" do + assert_raise RuntimeError, "prefix :ex2 is already mapped to another namespace", fn -> + PrefixMap.add!(@example2, :ex2, @ex_ns3) + end + end + end + + describe "merge/2" do + @ex_ns4 ~I + + test "when the prefix maps are disjunctive" do + other_prefix_map = PrefixMap.new(ex3: @ex_ns3) + assert PrefixMap.merge(@example2, other_prefix_map) == {:ok, @example3} + end + + test "when the prefix maps share some prefixes, but both map to the same namespace" do + other_prefix_map = PrefixMap.new(ex3: @ex_ns3) + assert PrefixMap.merge(@example3, other_prefix_map) == {:ok, @example3} + end + + test "when the prefix maps share some prefixes and both map to different namespaces" do + other_prefix_map = PrefixMap.new(ex3: @ex_ns4) + assert PrefixMap.merge(@example3, other_prefix_map) == {:error, "conflicting prefix mappings: :ex3"} + end + + test "when the second prefix map is given as a structure convertible to a prefix map" do + assert PrefixMap.merge(@example2, %{ex3: @ex_ns3}) == {:ok, @example3} + assert PrefixMap.merge(@example2, ex3: @ex_ns3) == {:ok, @example3} + end + + test "when the second argument is not convertible to a prefix map" do + assert_raise ArgumentError, ~S["not convertible" is not convertible to a RDF.PrefixMap], fn -> + PrefixMap.merge(@example2, "not convertible") + end + end + end + + describe "delete/2" do + test "when a mapping of the given prefix exists" do + assert PrefixMap.delete(@example2, :ex2) == @example1 + end + + test "when no mapping of the given prefix exists" do + assert PrefixMap.delete(@example1, :ex2) == @example1 + end + + test "with the prefix is given as a string" do + assert PrefixMap.delete(@example2, "ex2") == @example1 + end + end + + describe "namespace/2" do + test "when a mapping of the given prefix exists" do + assert PrefixMap.namespace(@example2, :ex2) == @ex_ns2 + end + + test "when no mapping of the given prefix exists" do + assert PrefixMap.namespace(@example1, :ex2) == nil + end + + test "with the prefix is given as a string" do + assert PrefixMap.namespace(@example2, "ex2") == @ex_ns2 + end + end + + describe "prefix/2" do + test "when a mapping to the given namespace exists" do + assert PrefixMap.prefix(@example2, @ex_ns2) == :ex2 + end + + test "when no mapping to the given namespace exists" do + assert PrefixMap.prefix(@example1, @ex_ns2) == nil + end + + test "with the namespace is given as a string" do + assert PrefixMap.prefix(@example2, to_string(@ex_ns2)) == :ex2 + end + end + + describe "has_prefix?/2" do + test "when a mapping of the given prefix exists" do + assert PrefixMap.has_prefix?(@example2, :ex2) == true + end + + test "when no mapping of the given prefix exists" do + assert PrefixMap.has_prefix?(@example1, :ex2) == false + end + + test "with the prefix is given as a string" do + assert PrefixMap.has_prefix?(@example2, "ex2") == true + end + end + + test "prefixes/1" do + assert PrefixMap.prefixes(@example2) == ~w[ex1 ex2]a + assert PrefixMap.prefixes(PrefixMap.new) == ~w[]a + end + + describe "namespaces/1" do + assert PrefixMap.namespaces(@example2) == [@ex_ns1, @ex_ns2] + assert PrefixMap.namespaces(PrefixMap.new) == ~w[]a + end + + describe "Enumerable protocol" do + test "Enum.count" do + assert Enum.count(PrefixMap.new) == 0 + assert Enum.count(@example1) == 1 + assert Enum.count(@example2) == 2 + end + + test "Enum.member?" do + assert Enum.member?(@example2, {:ex1, @ex_ns1}) + assert Enum.member?(@example2, {:ex2, @ex_ns2}) + refute Enum.member?(@example2, {:ex1, @ex_ns2}) + refute Enum.member?(@example2, {:ex2, @ex_ns3}) + end + + test "Enum.reduce" do + assert Enum.reduce(@example2, [], fn(mapping, acc) -> [mapping | acc] end) == + [{:ex2, @ex_ns2}, {:ex1, @ex_ns1}] + end + end +end