rdf-ex/lib/rdf/vocabulary_namespace.ex

253 lines
6.9 KiB
Elixir

defmodule RDF.Vocabulary.Namespace do
@moduledoc """
Defines a RDF Vocabulary as a `RDF.Namespace`.
## Strict vocabularies
What is a strict vocabulary and why should I use them over non-strict
vocabularies and define all terms ...
## Defining a vocabulary
There are two basic ways to define a vocabulary:
1. You can define all terms manually.
2. You can load all terms from a specified namespace in a given dataset or
graph.
Either way, you'll first have to define a new module for your vocabulary:
defmodule Example do
use RDF.Vocabulary.Namespace
defvocab EX,
base_uri: "http://www.example.com/ns/",
terms: ~w[Foo bar]
# Your term definitions
end
The `base_uri` argument with the URI prefix of all the terms in the defined
vocabulary is required and expects a valid URI ending with either a `"/"` or
a `"#"`.
## Reflection
`__base_uri__` and `__terms__` ...
"""
@vocabs_dir "priv/vocabs"
defmacro __using__(_opts) do
quote do
import unquote(__MODULE__)
end
end
@doc """
Defines a `RDF.Namespace` module for a RDF vocabulary.
"""
defmacro defvocab(name, opts) do
base_uri = base_uri!(opts)
file = file!(opts)
terms = terms!(opts)
strict = Keyword.get(opts, :strict, true)
case_separated_terms = group_terms_by_case(terms)
lowercased_terms = Map.get(case_separated_terms, :lowercased, [])
capitalized_terms = Map.get(case_separated_terms, :capitalized, [])
quote do
vocabdoc = Module.delete_attribute(__MODULE__, :vocabdoc)
defmodule unquote(name) do
@moduledoc vocabdoc
@behaviour RDF.Namespace
if unquote(file) do
@external_resource unquote(file)
end
@base_uri unquote(base_uri)
def __base_uri__, do: @base_uri
@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
define_vocab_terms unquote(lowercased_terms), unquote(base_uri)
if @strict do
def __resolve_term__(term) do
if Enum.member?(@capitalized_terms, term) do
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)
end
def unquote(:"$handle_undefined_function")(term, []) do
term_to_uri(@base_uri, term)
end
def unquote(:"$handle_undefined_function")(term, [subject | objects]) do
RDF.Description.new(subject, term_to_uri(@base_uri, term), objects)
end
end
Module.delete_attribute(__MODULE__, :tmp_uri)
end
end
end
defp base_uri!(opts) do
base_uri = Keyword.fetch!(opts, :base_uri)
unless is_binary(base_uri) and String.ends_with?(base_uri, ["/", "#"]) do
raise RDF.Namespace.InvalidVocabBaseURIError,
"a base_uri without a trailing '/' or '#' is invalid"
else
base_uri
end
end
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))
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
|> Enum.map(&to_string/1)
true ->
raise KeyError, key: ~w[terms data file], term: opts
end
end
def file!(opts) do
if file = Keyword.get(opts, :file) do
cond do
File.exists?(file) ->
file
File.exists?(expanded_file = Path.expand(file, @vocabs_dir)) ->
expanded_file
true ->
raise File.Error, path: file, action: "find", reason: :enoent
end
end
end
defp load_file(file) do
RDF.NTriples.read_file!(file)
end
defp 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)
def unquote(name)(subject, object) do
RDF.Description.new(subject, @tmp_uri, object)
end
def unquote(name)(subject, o1, o2),
do: unquote(name)(subject, [o1, o2])
def unquote(name)(subject, o1, o2, o3),
do: unquote(name)(subject, [o1, o2, o3])
def unquote(name)(subject, o1, o2, o3, o4),
do: unquote(name)(subject, [o1, o2, o3, o4])
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
data
|> RDF.Graph.resources # TODO: support also RDF.Datasets ...
# filter URIs
|> Stream.filter(fn
%URI{} -> true
_ -> false
end)
|> Stream.map(&to_string/1)
|> Stream.map(&(strip_base_uri(&1, base_uri)))
|> Enum.filter(&vocab_term?/1)
end
defp group_terms_by_case(terms) do
Enum.group_by terms, fn term ->
if lowercase?(term),
do: :lowercased,
else: :capitalized
end
end
defp lowercase?(term) when is_atom(term),
do: Atom.to_string(term) |> lowercase?
defp lowercase?(term),
do: term =~ ~r/^\p{Ll}/u
defp strip_base_uri(uri, base_uri) do
if String.starts_with?(uri, base_uri) do
String.replace_prefix(uri, base_uri, "")
end
end
defp vocab_term?(term) when is_binary(term) do
not String.contains?(term, "/")
end
defp vocab_term?(_), do: false
@doc false
def term_to_uri(base_uri, term) do
URI.parse(base_uri <> to_string(term))
end
end