Add RDF.Namespace builder

This commit is contained in:
Marcel Otto 2022-06-03 22:19:16 +02:00
parent ef626faf7d
commit 5919a9c93e
8 changed files with 407 additions and 3 deletions

View File

@ -1,5 +1,7 @@
locals_without_parens = [
defvocab: 2,
defnamespace: 2,
defnamespace: 3,
def_facet_constraint: 2,
def_applicable_facet: 1,
bgp: 1,

View File

@ -7,6 +7,10 @@ This project adheres to [Semantic Versioning](http://semver.org/) and
## Unreleased
### Added
- `RDF.Namespace` builders `defnamespace/3` and `create/4`
### Changed
- `RDF.Data.merge/2` and `RDF.Data.equal?/2` are now commutative, i.e. structs

View File

@ -8,6 +8,7 @@ defmodule RDF.Namespace do
"""
alias RDF.IRI
alias RDF.Namespace.Builder
import RDF.Guards
@ -19,7 +20,39 @@ defmodule RDF.Namespace do
@doc """
All terms of a `RDF.Namespace`.
"""
@callback __terms__() :: [atom]
@callback __terms__ :: [atom]
@doc """
All `RDF.IRI`s of a `RDF.Namespace`.
"""
@callback __iris__ :: [IRI.t()]
defmacro defnamespace({:__aliases__, _, [module]}, term_mapping, opts \\ []) do
env = __CALLER__
module = module(env, module)
quote do
result =
Builder.create!(
unquote(module),
unquote(term_mapping),
unquote(Macro.escape(env)),
unquote(opts)
)
alias unquote(module)
result
end
end
defdelegate create(module, term_mapping, location), to: Builder
defdelegate create(module, term_mapping, location, opts), to: Builder
defdelegate create!(module, term_mapping, location), to: Builder
defdelegate create!(module, term_mapping, location, opts), to: Builder
@doc false
def module(env, module), do: Module.concat(env.module, module)
@doc """
Resolves a qualified term to a `RDF.IRI`.

View File

@ -0,0 +1,200 @@
defmodule RDF.Namespace.Builder do
@moduledoc false
alias RDF.Description
import RDF.Utils
@type term_mapping :: map | keyword
@spec create(module, term_mapping, Macro.Env.t() | keyword, keyword) ::
{:ok, {:module, module(), binary(), term()}} | {:error, any}
def create(module, term_mapping, location, opts \\ []) do
moduledoc = opts[:moduledoc]
with {:ok, term_mapping} <- normalize_term_mapping(term_mapping) do
property_terms = property_terms(term_mapping)
body =
List.wrap(define_module_header(moduledoc)) ++
Enum.map(property_terms, &define_property_function/1) ++
List.wrap(
Keyword.get_lazy(opts, :namespace_functions, fn ->
define_namespace_functions(term_mapping)
end)
) ++
List.wrap(Keyword.get(opts, :add_after))
{:ok, Module.create(module, body, location)}
end
end
@spec create!(module, term_mapping, Macro.Env.t() | keyword, keyword) ::
{:module, module(), binary(), term()}
def create!(module, term_mapping, location, opts \\ []) do
case create(module, term_mapping, location, opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
defp define_module_header(moduledoc) do
quote do
@moduledoc unquote(moduledoc)
@behaviour Elixir.RDF.Namespace
import Kernel,
except: [
min: 2,
max: 2,
div: 2,
rem: 2,
abs: 1,
ceil: 1,
floor: 1,
elem: 2,
send: 2,
apply: 2,
destructure: 2,
get_and_update_in: 2,
get_in: 2,
pop_in: 2,
put_in: 2,
put_elem: 2,
update_in: 2,
raise: 2,
reraise: 2,
inspect: 2,
struct: 1,
struct: 2,
use: 1,
use: 2
]
end
end
defp define_property_function({term, iri}) do
quote do
@doc "<#{unquote(to_string(iri))}>"
def unquote(term)(), do: unquote(Macro.escape(iri))
@doc "`RDF.Description` builder for `#{unquote(term)}/0`"
def unquote(term)(subject, object)
def unquote(term)(%Description{} = subject, object) do
Description.add(subject, {unquote(Macro.escape(iri)), object})
end
def unquote(term)(subject, object) do
Description.new(subject, init: {unquote(Macro.escape(iri)), object})
end
@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
def define_namespace_functions(term_mapping) do
quote do
@term_mapping unquote(Macro.escape(term_mapping))
@impl Elixir.RDF.Namespace
def __terms__, do: Map.keys(@term_mapping)
@impl Elixir.RDF.Namespace
def __iris__, do: Map.values(@term_mapping)
@impl Elixir.RDF.Namespace
def __resolve_term__(term) do
if iri = @term_mapping[term] do
{:ok, iri}
else
{:error,
%Elixir.RDF.Namespace.UndefinedTermError{
message: "undefined term #{term} in namespace #{__MODULE__}"
}}
end
end
end
end
defp normalize_term_mapping(term_mapping) do
Enum.reduce_while(term_mapping, {:ok, %{}}, fn {term, iri}, {:ok, normalized} ->
if valid_term?(term) do
{:cont, {:ok, Map.put(normalized, term, RDF.iri(iri))}}
else
{:halt,
{:error, %RDF.Namespace.InvalidTermError{message: "invalid term: #{inspect(term)}"}}}
end
end)
end
defp property_terms(term_mapping) do
for {term, iri} <- term_mapping, downcase?(term), into: %{} do
{term, iri}
end
end
@reserved_terms ~w[
and
or
xor
in
fn
def
defp
defdelegate
defexception
defguard
defguardp
defimpl
defmacro
defmacrop
defmodule
defoverridable
defprotocol
defstruct
function_exported?
macro_exported?
when
if
unless
for
case
with
quote
unquote
unquote_splicing
alias
import
require
super
__aliases__
]a
@doc false
def reserved_terms, do: @reserved_terms
def reserved_term?(term) when term in @reserved_terms, do: true
def reserved_term?(_), do: false
def valid_characters?(term) when is_atom(term),
do: term |> Atom.to_string() |> valid_characters?()
def valid_characters?(term), do: Regex.match?(~r/^[a-zA-Z_]\w*$/, term)
def valid_term?(term), do: not reserved_term?(term) and valid_characters?(term)
end

View File

@ -119,7 +119,7 @@ defmodule RDF.Vocabulary.Namespace do
@doc """
Returns all known IRIs of the vocabulary.
"""
@spec __iris__ :: [Elixir.RDF.IRI.t()]
@impl Elixir.RDF.Namespace
def __iris__ do
@terms
|> Enum.map(fn

View File

@ -0,0 +1,21 @@
defmodule RDF.TestNamespaces do
import RDF.Sigils
import RDF.Namespace
alias RDF.PropertyMap
defnamespace SimpleNS,
[
foo: ~I<http://example.com/foo>,
bar: "http://example.com/bar",
Baz: ~I<http://example.com/Baz>,
Baaz: "http://example.com/Baaz"
],
moduledoc: "Example doc"
defnamespace NSfromPropertyMap,
PropertyMap.new(
foo: ~I<http://example.com/foo>,
bar: "http://example.com/bar"
)
end

View File

@ -0,0 +1,55 @@
defmodule RDF.Namespace.BuilderTest do
use RDF.Test.Case
alias RDF.Namespace.Builder
import RDF.Sigils
@compile {:no_warn_undefined, ToplevelNS}
describe "create/3" do
test "creates a module" do
assert {:ok, {:module, ToplevelNS, _, _}} =
Builder.create(
ToplevelNS,
[foo: ~I<http://example.com/foo>],
Macro.Env.location(__ENV__)
)
assert Elixir.ToplevelNS.foo() == ~I<http://example.com/foo>
end
test "terms with invalid characters" do
%{
number_at_start: "42foo",
colon: "foo:",
bracket: "f(oo",
square_bracket: "f[oo"
}
|> Enum.each(fn {label, invalid_term} ->
assert Builder.create(
:"NamespaceWithInvalidCharacter#{label}",
[{invalid_term, ~I<http://example.com/invalid>}],
Macro.Env.location(__ENV__)
) ==
{:error,
%RDF.Namespace.InvalidTermError{
message: "invalid term: #{inspect(invalid_term)}"
}}
end)
end
test "terms with a special meaning for Elixir" do
Enum.each(Builder.reserved_terms(), fn invalid_term ->
assert Builder.create(
:"NamespaceWithInvalidTerm#{invalid_term}",
[{invalid_term, ~I<http://example.com/invalid>}],
Macro.Env.location(__ENV__)
) ==
{:error,
%RDF.Namespace.InvalidTermError{
message: "invalid term: #{inspect(invalid_term)}"
}}
end)
end
end
end

View File

@ -1,5 +1,94 @@
defmodule RDF.NamespaceTest do
use ExUnit.Case
use RDF.Test.Case
doctest RDF.Namespace
alias RDF.Namespace
import RDF.Sigils
alias RDF.TestNamespaces.{SimpleNS, NSfromPropertyMap}
@compile {:no_warn_undefined, RDF.NamespaceTest.RelativeNS}
describe "defnamespace/2" do
test "create module is relative to current namespace" do
assert {:module, RDF.NamespaceTest.RelativeNS, _, _} =
Namespace.defnamespace(RelativeNS,
foo: ~I<http://example.com/foo>
)
assert RDF.NamespaceTest.RelativeNS.foo() == ~I<http://example.com/foo>
end
end
describe "property functions" do
test "returns IRI without args" do
assert SimpleNS.foo() == ~I<http://example.com/foo>
assert SimpleNS.bar() == ~I<http://example.com/bar>
assert NSfromPropertyMap.foo() == ~I<http://example.com/foo>
assert NSfromPropertyMap.bar() == ~I<http://example.com/bar>
end
test "description builder" do
assert ~I<http://example.com/foo> |> SimpleNS.foo(~I<http://example.com/bar>) ==
RDF.description(~I<http://example.com/foo>,
init: {SimpleNS.foo(), ~I<http://example.com/bar>}
)
assert EX.Foo |> SimpleNS.foo(EX.Bar) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), ~I<http://example.com/Bar>}
)
assert EX.Foo |> SimpleNS.foo([1, 2, 3]) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), [1, 2, 3]}
)
assert EX.Foo |> SimpleNS.foo(1, 2) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), [1, 2]}
)
assert EX.Foo |> SimpleNS.foo(1, 2, 3) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), [1, 2, 3]}
)
assert EX.Foo |> SimpleNS.foo(1, 2, 3, 4) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), [1, 2, 3, 4]}
)
assert EX.Foo |> SimpleNS.foo(1, 2, 3, 4, 5) ==
RDF.description(~I<http://example.com/Foo>,
init: {SimpleNS.foo(), [1, 2, 3, 4, 5]}
)
end
end
test "resolving module name atoms for non-property terms" do
assert RDF.iri(SimpleNS.Baz) == ~I<http://example.com/Baz>
assert RDF.iri(SimpleNS.Baaz) == ~I<http://example.com/Baaz>
end
test "__terms__" do
assert SimpleNS.__terms__() == [:Baaz, :Baz, :bar, :foo]
assert NSfromPropertyMap.__terms__() == [:bar, :foo]
end
test "__iris__" do
assert SimpleNS.__iris__() == [
~I<http://example.com/Baaz>,
~I<http://example.com/Baz>,
~I<http://example.com/bar>,
~I<http://example.com/foo>
]
assert NSfromPropertyMap.__iris__() == [
~I<http://example.com/bar>,
~I<http://example.com/foo>
]
end
end