From 5919a9c93ede0924695fb1f876cc38d03cfa5592 Mon Sep 17 00:00:00 2001 From: Marcel Otto Date: Fri, 3 Jun 2022 22:19:16 +0200 Subject: [PATCH] Add RDF.Namespace builder --- .formatter.exs | 2 + CHANGELOG.md | 4 + lib/rdf/namespace.ex | 35 ++++- lib/rdf/namespace/builder.ex | 200 +++++++++++++++++++++++++++ lib/rdf/vocabulary_namespace.ex | 2 +- test/support/test_namespaces.ex | 21 +++ test/unit/namespace/builder_test.exs | 55 ++++++++ test/unit/namespace_test.exs | 91 +++++++++++- 8 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 lib/rdf/namespace/builder.ex create mode 100644 test/support/test_namespaces.ex create mode 100644 test/unit/namespace/builder_test.exs diff --git a/.formatter.exs b/.formatter.exs index e1a8412..66f927d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,7 @@ locals_without_parens = [ defvocab: 2, + defnamespace: 2, + defnamespace: 3, def_facet_constraint: 2, def_applicable_facet: 1, bgp: 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f75647..ea3412c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/rdf/namespace.ex b/lib/rdf/namespace.ex index 88dc71e..33efdac 100644 --- a/lib/rdf/namespace.ex +++ b/lib/rdf/namespace.ex @@ -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`. diff --git a/lib/rdf/namespace/builder.ex b/lib/rdf/namespace/builder.ex new file mode 100644 index 0000000..96c1530 --- /dev/null +++ b/lib/rdf/namespace/builder.ex @@ -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 diff --git a/lib/rdf/vocabulary_namespace.ex b/lib/rdf/vocabulary_namespace.ex index e0bad48..091da48 100644 --- a/lib/rdf/vocabulary_namespace.ex +++ b/lib/rdf/vocabulary_namespace.ex @@ -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 diff --git a/test/support/test_namespaces.ex b/test/support/test_namespaces.ex new file mode 100644 index 0000000..08aec3a --- /dev/null +++ b/test/support/test_namespaces.ex @@ -0,0 +1,21 @@ +defmodule RDF.TestNamespaces do + import RDF.Sigils + import RDF.Namespace + + alias RDF.PropertyMap + + defnamespace SimpleNS, + [ + foo: ~I, + bar: "http://example.com/bar", + Baz: ~I, + Baaz: "http://example.com/Baaz" + ], + moduledoc: "Example doc" + + defnamespace NSfromPropertyMap, + PropertyMap.new( + foo: ~I, + bar: "http://example.com/bar" + ) +end diff --git a/test/unit/namespace/builder_test.exs b/test/unit/namespace/builder_test.exs new file mode 100644 index 0000000..3909689 --- /dev/null +++ b/test/unit/namespace/builder_test.exs @@ -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], + Macro.Env.location(__ENV__) + ) + + assert Elixir.ToplevelNS.foo() == ~I + 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}], + 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}], + Macro.Env.location(__ENV__) + ) == + {:error, + %RDF.Namespace.InvalidTermError{ + message: "invalid term: #{inspect(invalid_term)}" + }} + end) + end + end +end diff --git a/test/unit/namespace_test.exs b/test/unit/namespace_test.exs index ada595b..20697cb 100644 --- a/test/unit/namespace_test.exs +++ b/test/unit/namespace_test.exs @@ -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 + ) + + assert RDF.NamespaceTest.RelativeNS.foo() == ~I + end + end + + describe "property functions" do + test "returns IRI without args" do + assert SimpleNS.foo() == ~I + assert SimpleNS.bar() == ~I + + assert NSfromPropertyMap.foo() == ~I + assert NSfromPropertyMap.bar() == ~I + end + + test "description builder" do + assert ~I |> SimpleNS.foo(~I) == + RDF.description(~I, + init: {SimpleNS.foo(), ~I} + ) + + assert EX.Foo |> SimpleNS.foo(EX.Bar) == + RDF.description(~I, + init: {SimpleNS.foo(), ~I} + ) + + assert EX.Foo |> SimpleNS.foo([1, 2, 3]) == + RDF.description(~I, + init: {SimpleNS.foo(), [1, 2, 3]} + ) + + assert EX.Foo |> SimpleNS.foo(1, 2) == + RDF.description(~I, + init: {SimpleNS.foo(), [1, 2]} + ) + + assert EX.Foo |> SimpleNS.foo(1, 2, 3) == + RDF.description(~I, + init: {SimpleNS.foo(), [1, 2, 3]} + ) + + assert EX.Foo |> SimpleNS.foo(1, 2, 3, 4) == + RDF.description(~I, + init: {SimpleNS.foo(), [1, 2, 3, 4]} + ) + + assert EX.Foo |> SimpleNS.foo(1, 2, 3, 4, 5) == + RDF.description(~I, + 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 + assert RDF.iri(SimpleNS.Baaz) == ~I + end + + test "__terms__" do + assert SimpleNS.__terms__() == [:Baaz, :Baz, :bar, :foo] + assert NSfromPropertyMap.__terms__() == [:bar, :foo] + end + + test "__iris__" do + assert SimpleNS.__iris__() == [ + ~I, + ~I, + ~I, + ~I + ] + + assert NSfromPropertyMap.__iris__() == [ + ~I, + ~I + ] + end end