diff --git a/lib/rdf/exceptions.ex b/lib/rdf/exceptions.ex index 13eae70..bd2f18b 100644 --- a/lib/rdf/exceptions.ex +++ b/lib/rdf/exceptions.ex @@ -35,6 +35,14 @@ defmodule RDF.Namespace.InvalidVocabBaseURIError do defexception [:message] end +defmodule RDF.Namespace.InvalidTermError do + defexception [:message] +end + +defmodule RDF.Namespace.InvalidAliasError do + defexception [:message] +end + defmodule RDF.Namespace.UndefinedTermError do defexception [:message] end diff --git a/lib/rdf/vocabulary_namespace.ex b/lib/rdf/vocabulary_namespace.ex index 3428897..38e3c4b 100644 --- a/lib/rdf/vocabulary_namespace.ex +++ b/lib/rdf/vocabulary_namespace.ex @@ -53,12 +53,11 @@ defmodule RDF.Vocabulary.Namespace do """ defmacro defvocab(name, opts) do base_uri = base_uri!(opts) - file = file!(opts) - terms = terms!(opts) - strict = Keyword.get(opts, :strict, true) + file = filename!(opts) + terms = terms!(opts) |> term_mapping!(opts) + strict = strict?(opts) case_separated_terms = group_terms_by_case(terms) - lowercased_terms = Map.get(case_separated_terms, :lowercased, []) - capitalized_terms = Map.get(case_separated_terms, :capitalized, []) + lowercased_terms = Map.get(case_separated_terms, :lowercased, %{}) quote do vocabdoc = Module.delete_attribute(__MODULE__, :vocabdoc) @@ -78,27 +77,28 @@ defmodule RDF.Vocabulary.Namespace do @strict unquote(strict) def __strict__, do: @strict - @lowercased_terms unquote(lowercased_terms |> Enum.map(&String.to_atom/1)) - @capitalized_terms unquote(capitalized_terms |> Enum.map(&String.to_atom/1)) - @terms @lowercased_terms ++ @capitalized_terms - def __terms__, do: @terms + @terms unquote(Macro.escape(terms)) + def __terms__, do: @terms |> Map.keys define_vocab_terms unquote(lowercased_terms), unquote(base_uri) - if @strict do - def __resolve_term__(term) do - if Enum.member?(@capitalized_terms, term) do + def __resolve_term__(term) do + case @terms[term] do + nil -> + if @strict do + raise RDF.Namespace.UndefinedTermError, + "undefined term #{term} in strict vocabulary #{__MODULE__}" + else + term_to_uri(@base_uri, term) + end + true -> term_to_uri(@base_uri, term) - else - raise RDF.Namespace.UndefinedTermError, - "undefined term #{term} in strict vocabulary #{__MODULE__}" - end - end - else - def __resolve_term__(term) do - term_to_uri(@base_uri, term) + original_term -> + term_to_uri(@base_uri, original_term) end + end + if not @strict do def unquote(:"$handle_undefined_function")(term, []) do term_to_uri(@base_uri, term) end @@ -113,6 +113,52 @@ defmodule RDF.Vocabulary.Namespace do end end + defmacro define_vocab_terms(terms, base_uri) do + terms + |> Stream.map(fn + {term, true} -> {term, term} + {term, original_term} -> {term, original_term} + end) + |> Enum.map(fn {term, uri_suffix} -> +# TODO: Why does this way of precompiling the URI not work? We're getting an "invalid quoted expression: %URI{...}" +# uri = term_to_uri(base_uri, term) +# quote bind_quoted: [uri: Macro.escape(uri), term: String.to_atom(term)] do +## @doc "<#{@tmp_uri}>" +# def unquote(term)() do +# unquote(uri) +# end +# end +# Temporary workaround: + quote do + @tmp_uri term_to_uri(@base_uri, unquote(uri_suffix)) + @doc "<#{@tmp_uri}>" + def unquote(term)(), do: @tmp_uri + + @doc "`RDF.Description` builder for <#{@tmp_uri}>" + def unquote(term)(subject, object) do + RDF.Description.new(subject, @tmp_uri, object) + end + + # Is there a better way to support multiple objects via arguments? + @doc false + def unquote(term)(subject, o1, o2), + do: unquote(term)(subject, [o1, o2]) + @doc false + def unquote(term)(subject, o1, o2, o3), + do: unquote(term)(subject, [o1, o2, o3]) + @doc false + def unquote(term)(subject, o1, o2, o3, o4), + do: unquote(term)(subject, [o1, o2, o3, o4]) + @doc false + def unquote(term)(subject, o1, o2, o3, o4, o5), + do: unquote(term)(subject, [o1, o2, o3, o4, o5]) + end + end) + end + + defp strict?(opts), + do: Keyword.get(opts, :strict, true) + defp base_uri!(opts) do base_uri = Keyword.fetch!(opts, :base_uri) unless is_binary(base_uri) and String.ends_with?(base_uri, ["/", "#"]) do @@ -126,91 +172,88 @@ defmodule RDF.Vocabulary.Namespace do def terms!(opts) do cond do Keyword.has_key?(opts, :file) -> - opts - |> Keyword.delete(:file) - |> Keyword.put(:data, load_file(file!(opts))) - |> terms! - data = Keyword.get(opts, :data) -> - # TODO: support also RDF.Datasets ... - data = unless match?(%RDF.Graph{}, data) do - # TODO: find an alternative to Code.eval_quoted - {data, _ } = Code.eval_quoted(data, [], data_env()) - data - else - data - end - data_vocab_terms(data, Keyword.fetch!(opts, :base_uri)) + filename!(opts) + |> load_file + |> terms_from_rdf_data!(opts) + rdf_data = Keyword.get(opts, :data) -> + terms_from_rdf_data!(rdf_data, opts) terms = Keyword.get(opts, :terms) -> # TODO: find an alternative to Code.eval_quoted - We want to support that the terms can be given as sigils ... - {terms, _ } = Code.eval_quoted(terms, [], data_env()) + {terms, _ } = Code.eval_quoted(terms, [], rdf_data_env()) terms - |> Enum.map(&to_string/1) + |> Enum.map(fn + term when is_atom(term) -> term + term when is_binary(term) -> String.to_atom(term) + term -> + raise RDF.Namespace.InvalidTermError, + "'#{term}' is not a valid vocabulary term" + end) true -> raise KeyError, key: ~w[terms data file], term: opts end end - def file!(opts) do - if file = Keyword.get(opts, :file) do + # TODO: support also RDF.Datasets ... + defp terms_from_rdf_data!(%RDF.Graph{} = rdf_data, opts) do + rdf_data_vocab_terms(rdf_data, Keyword.fetch!(opts, :base_uri)) + end + + defp terms_from_rdf_data!(rdf_data, opts) do + # TODO: find an alternative to Code.eval_quoted + {rdf_data, _} = Code.eval_quoted(rdf_data, [], rdf_data_env()) + terms_from_rdf_data!(rdf_data, opts) + end + + + def term_mapping!(terms, opts) do + terms = Map.new terms, fn + term when is_atom(term) -> {term, true} + term -> {String.to_atom(term), true} + end + Keyword.get(opts, :alias, []) + |> Enum.reduce(terms, fn {alias, original_term}, terms -> + term = String.to_atom(original_term) + cond do + Map.get(terms, alias) == true -> + raise RDF.Namespace.InvalidAliasError, + "alias '#{alias}' already defined" + + strict?(opts) and not Map.has_key?(terms, term) -> + raise RDF.Namespace.InvalidAliasError, + "term '#{original_term}' is not a term in this vocabulary" + + Map.get(terms, term, true) != true -> + raise RDF.Namespace.InvalidAliasError, + "'#{original_term}' is already an alias" + + true -> + Map.put(terms, alias, to_string(original_term)) + end + end) + end + + def filename!(opts) do + if filename = Keyword.get(opts, :file) do cond do - File.exists?(file) -> - file - File.exists?(expanded_file = Path.expand(file, @vocabs_dir)) -> - expanded_file + File.exists?(filename) -> + filename + File.exists?(expanded_filename = Path.expand(filename, @vocabs_dir)) -> + expanded_filename true -> - raise File.Error, path: file, action: "find", reason: :enoent + raise File.Error, path: filename, action: "find", reason: :enoent end end end defp load_file(file) do - RDF.NTriples.read_file!(file) + RDF.NTriples.read_file!(file) # TODO: support other formats end - defp data_env do + defp rdf_data_env do __ENV__ end - - defmacro define_vocab_terms(terms, base_uri) do - Enum.map terms, fn term -> - name = String.to_atom(term) -# TODO: Why does this way of precompiling the URI not work? We're getting an "invalid quoted expression: %URI{...}" -# uri = term_to_uri(base_uri, term) -# quote bind_quoted: [uri: Macro.escape(uri), term: String.to_atom(term)] do -## @doc "<#{@tmp_uri}>" -# def unquote(term)() do -# unquote(uri) -# end -# end -# Temporary workaround: - quote do - @tmp_uri term_to_uri(@base_uri, unquote(term)) - @doc "<#{@tmp_uri}>" - def unquote(name)(), do: @tmp_uri - - @doc "`RDF.Description` builder for <#{@tmp_uri}>" - def unquote(name)(subject, object) do - RDF.Description.new(subject, @tmp_uri, object) - end - - @doc false - def unquote(name)(subject, o1, o2), - do: unquote(name)(subject, [o1, o2]) - @doc false - def unquote(name)(subject, o1, o2, o3), - do: unquote(name)(subject, [o1, o2, o3]) - @doc false - def unquote(name)(subject, o1, o2, o3, o4), - do: unquote(name)(subject, [o1, o2, o3, o4]) - @doc false - def unquote(name)(subject, o1, o2, o3, o4, o5), - do: unquote(name)(subject, [o1, o2, o3, o4, o5]) - end - end - end - - defp data_vocab_terms(data, base_uri) do + defp rdf_data_vocab_terms(data, base_uri) do data |> RDF.Graph.resources # TODO: support also RDF.Datasets ... # filter URIs @@ -220,15 +263,20 @@ defmodule RDF.Vocabulary.Namespace do end) |> Stream.map(&to_string/1) |> Stream.map(&(strip_base_uri(&1, base_uri))) - |> Enum.filter(&vocab_term?/1) + |> Stream.filter(&vocab_term?/1) + |> Enum.map(&String.to_atom/1) end defp group_terms_by_case(terms) do - Enum.group_by terms, fn term -> - if lowercase?(term), - do: :lowercased, - else: :capitalized - end + terms + |> Enum.group_by(fn {term, _} -> + if lowercase?(term), + do: :lowercased, + else: :capitalized + end) + |> Map.new(fn {group, term_mapping} -> + {group, Map.new(term_mapping)} + end) end defp lowercase?(term) when is_atom(term), diff --git a/test/unit/vocabulary_namespace_test.exs b/test/unit/vocabulary_namespace_test.exs index 91b4af8..9067a35 100644 --- a/test/unit/vocabulary_namespace_test.exs +++ b/test/unit/vocabulary_namespace_test.exs @@ -36,6 +36,27 @@ defmodule RDF.Vocabulary.NamespaceTest do base_uri: "http://example.com/example4#", terms: ~w[foo Bar], strict: false + + defvocab Example5, + base_uri: "http://example.com/example5#", + terms: ~w[term1 Term2 Term-3 term-4], + alias: [ + Term1: "term1", + term2: "Term2", + Term3: "Term-3", + term4: "term-4", + ] + + defvocab Example6, + base_uri: "http://example.com/example6#", + terms: ~w[], + alias: [ + Term1: "term1", + term2: "Term2", + Term3: "Term-3", + term4: "term-4", + ], + strict: false end @@ -95,6 +116,60 @@ defmodule RDF.Vocabulary.NamespaceTest do end end + test "when trying to map an already existing term, an error is raised" do + assert_raise RDF.Namespace.InvalidAliasError, fn -> + defmodule BadNS6 do + use RDF.Vocabulary.Namespace + + defvocab Example, + base_uri: "http://example.com/ex6#", + terms: ~w[foo bar], + alias: [foo: "bar"] + end + end + end + + test "when strict and trying to map to a term not in the vocabulary, an error is raised" do + assert_raise RDF.Namespace.InvalidAliasError, fn -> + defmodule BadNS7 do + use RDF.Vocabulary.Namespace + + defvocab Example, + base_uri: "http://example.com/ex7#", + terms: ~w[], + alias: [foo: "bar"] + end + end + end + + test "when defining an alias for an alias, an error is raised" do + assert_raise RDF.Namespace.InvalidAliasError, fn -> + defmodule BadNS8 do + use RDF.Vocabulary.Namespace + + defvocab Example, + base_uri: "http://example.com/ex8#", + terms: ~w[bar], + alias: [foo: "bar", baz: "foo"] + end + end + end + + test "defining multiple aliases for a term" do + defmodule BadNS9 do + use RDF.Vocabulary.Namespace + + defvocab Example, + base_uri: "http://example.com/ex8#", + terms: ~w[bar Bar], + alias: [foo: "bar", baz: "bar", + Foo: "Bar", Baz: "Bar"] + end + alias BadNS9.Example + assert Example.foo == Example.baz + assert RDF.uri(Example.foo) == RDF.uri(Example.baz) + end + end test "__base_uri__ returns the base_uri" do @@ -105,20 +180,38 @@ defmodule RDF.Vocabulary.NamespaceTest do assert SlashVocab.__base_uri__ == "http://example.com/example2/" end - test "__terms__ returns a list of all defined terms" do - alias TestNS.Example1 - assert length(Example1.__terms__) == 2 - assert :foo in Example1.__terms__ - assert :Bar in Example1.__terms__ + + describe "__terms__" do + alias TestNS.{Example1, Example5} + + test "includes all defined terms" do + assert length(Example1.__terms__) == 2 + assert :foo in Example1.__terms__ + assert :Bar in Example1.__terms__ + end + + test "includes aliases" do + assert length(Example5.__terms__) == 8 + assert :term1 in Example5.__terms__ + assert :Term1 in Example5.__terms__ + assert :term2 in Example5.__terms__ + assert :Term2 in Example5.__terms__ + assert :Term3 in Example5.__terms__ + assert :term4 in Example5.__terms__ + assert :"Term-3" in Example5.__terms__ + assert :"term-4" in Example5.__terms__ + end end + @tag skip: "TODO: Can we make RDF.uri(:foo) an undefined function call with guards or in another way?" test "resolving an unqualified term raises an error" do - assert_raise UndefinedFunctionError, fn -> RDF.uri(:foo) end + assert_raise RDF.Namespace.UndefinedTermError, fn -> RDF.uri(:foo) end end describe "term resolution in a strict vocab namespace" do alias TestNS.{Example1, Example2, Example3} + test "undefined terms" do assert_raise UndefinedFunctionError, fn -> Example1.undefined @@ -180,6 +273,39 @@ defmodule RDF.Vocabulary.NamespaceTest do end + describe "term resolution of aliases on a strict vocabulary" do + alias TestNS.Example5 + + test "the alias resolves to the correct URI" do + assert RDF.uri(Example5.Term1) == URI.parse("http://example.com/example5#term1") + assert RDF.uri(Example5.term2) == URI.parse("http://example.com/example5#Term2") + assert RDF.uri(Example5.Term3) == URI.parse("http://example.com/example5#Term-3") + assert RDF.uri(Example5.term4) == URI.parse("http://example.com/example5#term-4") + end + + test "the old term remains resolvable" do + assert RDF.uri(Example5.term1) == URI.parse("http://example.com/example5#term1") + assert RDF.uri(Example5.Term2) == URI.parse("http://example.com/example5#Term2") + end + end + + describe "term resolution of aliases on a non-strict vocabulary" do + alias TestNS.Example6 + + test "the alias resolves to the correct URI" do + assert RDF.uri(Example6.Term1) == URI.parse("http://example.com/example6#term1") + assert RDF.uri(Example6.term2) == URI.parse("http://example.com/example6#Term2") + assert RDF.uri(Example6.Term3) == URI.parse("http://example.com/example6#Term-3") + assert RDF.uri(Example6.term4) == URI.parse("http://example.com/example6#term-4") + end + + test "the old term remains resolvable" do + assert RDF.uri(Example6.term1) == URI.parse("http://example.com/example6#term1") + assert RDF.uri(Example6.Term2) == URI.parse("http://example.com/example6#Term2") + end + end + + describe "Description DSL" do alias TestNS.{EX, EXS}