diff --git a/.formatter.exs b/.formatter.exs index bed7f06..2743f09 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -2,7 +2,8 @@ locals_without_parens = [ defvocab: 2, def_facet_constraint: 2, def_applicable_facet: 1, - bgp: 1 + bgp: 1, + build: 2 ] [ diff --git a/.iex.exs b/.iex.exs index 3c20795..62e4a40 100644 --- a/.iex.exs +++ b/.iex.exs @@ -1,6 +1,8 @@ import RDF.Sigils import RDF.Guards +require RDF.Graph + alias RDF.NS alias RDF.NS.{RDFS, OWL, SKOS} diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b589c..8cc49ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/) and ### Added +- a `RDF.Graph` builder DSL available under the `RDF.Graph.build/2` function - `RDF.Graph.new/2` and `RDF.Graph.add/2` support the addition of `RDF.Dataset`s - new guards in `RDF.Guards`: `is_statement/1` and `is_quad/1` diff --git a/lib/rdf/graph.ex b/lib/rdf/graph.ex index 03562f2..838b9f9 100644 --- a/lib/rdf/graph.ex +++ b/lib/rdf/graph.ex @@ -16,6 +16,7 @@ defmodule RDF.Graph do @behaviour Access alias RDF.{Description, IRI, PrefixMap, PropertyMap} + alias RDF.Graph.Builder alias RDF.Star.Statement @type graph_description :: %{Statement.subject() => Description.t()} @@ -136,6 +137,10 @@ defmodule RDF.Graph do defp init(graph, fun, opts) when is_function(fun), do: add(graph, fun.(), opts) defp init(graph, data, opts), do: add(graph, data, opts) + defmacro build(opts \\ [], do: block) do + Builder.build(block, opts) + end + @doc """ Removes all triples from `graph`. diff --git a/lib/rdf/graph_builder.ex b/lib/rdf/graph_builder.ex new file mode 100644 index 0000000..487c619 --- /dev/null +++ b/lib/rdf/graph_builder.ex @@ -0,0 +1,130 @@ +defmodule RDF.Graph.Builder do + alias RDF.{Description, Graph, Dataset, PrefixMap} + + import RDF.Guards + + defmodule Error do + defexception [:message] + end + + defmodule Helper do + defdelegate a(), to: RDF.NS.RDF, as: :type + defdelegate a(s, o), to: RDF.NS.RDF, as: :type + defdelegate a(s, o1, o2), to: RDF.NS.RDF, as: :type + defdelegate a(s, o1, o2, o3), to: RDF.NS.RDF, as: :type + defdelegate a(s, o1, o2, o3, o4), to: RDF.NS.RDF, as: :type + defdelegate a(s, o1, o2, o3, o4, o5), to: RDF.NS.RDF, as: :type + end + + def build({:__block__, _, block}, opts) do + {declarations, data} = Enum.split_with(block, &declaration?/1) + {prefixes, declarations} = extract_prefixes(declarations) + {base, declarations} = extract_base(declarations) + + quote do + alias RDF.XSD + alias RDF.NS.{RDFS, OWL} + + import RDF.Sigils + import Helper + + unquote(declarations) + + RDF.Graph.Builder.do_build(unquote(data), unquote(opts), unquote(prefixes), unquote(base)) + end + end + + def build(single, opts) do + build({:__block__, [], List.wrap(single)}, opts) + end + + @doc false + def do_build(data, opts, prefixes, base) do + RDF.graph(graph_opts(opts, prefixes, base)) + |> Graph.add(Enum.filter(data, &rdf?/1)) + end + + defp graph_opts(opts, prefixes, base) do + opts + |> set_base_opt(base) + |> set_prefix_opt(prefixes) + end + + defp set_base_opt(opts, nil), do: opts + defp set_base_opt(opts, base), do: Keyword.put(opts, :base_iri, base) + + defp set_prefix_opt(opts, []), do: opts + + defp set_prefix_opt(opts, prefixes) do + Keyword.update(opts, :prefixes, RDF.default_prefixes(prefixes), fn opt_prefixes -> + PrefixMap.new(prefixes) + |> PrefixMap.merge!(opt_prefixes, :ignore) + end) + end + + defp extract_base(declarations) do + {base, declarations} = + Enum.reduce(declarations, {nil, []}, fn + {:@, line, [{:base, _, [{:__aliases__, _, ns}] = aliases}]}, {_, declarations} -> + {Module.concat(ns), [{:alias, line, aliases} | declarations]} + + {:@, _, [{:base, _, [base]}]}, {_, declarations} -> + {base, declarations} + + declaration, {base, declarations} -> + {base, [declaration | declarations]} + end) + + {base, Enum.reverse(declarations)} + end + + defp extract_prefixes(declarations) do + {prefixes, declarations} = + Enum.reduce(declarations, {[], []}, fn + {:@, line, [{:prefix, _, [[{prefix, {:__aliases__, _, ns} = aliases}]]}]}, + {prefixes, declarations} -> + {[prefix(prefix, ns) | prefixes], [{:alias, line, [aliases]} | declarations]} + + {:@, line, [{:prefix, _, [{:__aliases__, _, ns}] = aliases}]}, {prefixes, declarations} -> + {[prefix(ns) | prefixes], [{:alias, line, aliases} | declarations]} + + declaration, {prefixes, declarations} -> + {prefixes, [declaration | declarations]} + end) + + {prefixes, Enum.reverse(declarations)} + end + + defp prefix(namespace) do + namespace + |> Enum.reverse() + |> hd() + |> to_string() + |> Macro.underscore() + |> String.to_atom() + |> prefix(namespace) + end + + defp prefix(prefix, namespace), do: {prefix, Module.concat(namespace)} + + defp declaration?({:=, _, _}), do: true + defp declaration?({:@, _, [{:prefix, _, _}]}), do: true + defp declaration?({:@, _, [{:base, _, _}]}), do: true + defp declaration?({:alias, _, _}), do: true + defp declaration?({:import, _, _}), do: true + defp declaration?({:require, _, _}), do: true + defp declaration?({:use, _, _}), do: true + defp declaration?(_), do: false + + defp rdf?(nil), do: false + defp rdf?(:ok), do: false + defp rdf?(%Description{}), do: true + defp rdf?(%Graph{}), do: true + defp rdf?(%Dataset{}), do: true + defp rdf?(statement) when is_statement(statement), do: true + defp rdf?(list) when is_list(list), do: true + + defp rdf?(invalid) do + raise Error, message: "invalid RDF data: #{inspect(invalid)}" + end +end diff --git a/test/unit/graph_builder_test.exs b/test/unit/graph_builder_test.exs new file mode 100644 index 0000000..9140657 --- /dev/null +++ b/test/unit/graph_builder_test.exs @@ -0,0 +1,462 @@ +defmodule RDF.Graph.BuilderTest do + use ExUnit.Case + + require RDF.Graph + + doctest RDF.Graph.Builder + + alias RDF.Graph.Builder + + import ExUnit.CaptureLog + + defmodule TestNS do + use RDF.Vocabulary.Namespace + defvocab EX, base_iri: "http://example.com/", terms: [], strict: false + defvocab Custom, base_iri: "http://custom.com/foo#", terms: [], strict: false + defvocab ImportTest, base_iri: "http://import.com/bar#", terms: [:foo, :Bar] + end + + @compile {:no_warn_undefined, __MODULE__.TestNS.EX} + @compile {:no_warn_undefined, __MODULE__.TestNS.Custom} + + alias __MODULE__.TestNS.EX + alias RDF.NS + + defmodule UseTest do + defmacro __using__(_opts) do + quote do + {EX.This, EX.ShouldNotAppearIn, EX.Graph} + end + end + end + + test "single statement" do + graph = + RDF.Graph.build do + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O)) + end + + test "multiple statements" do + graph = + RDF.Graph.build do + EX.S1 |> EX.p1(EX.O1) + EX.S2 |> EX.p2(EX.O2) + end + + assert graph == + RDF.graph([ + EX.S1 |> EX.p1(EX.O1), + EX.S2 |> EX.p2(EX.O2) + ]) + end + + test "different kinds of description forms" do + graph = + RDF.Graph.build do + EX.S1 + |> EX.p11(EX.O11, EX.O12) + |> EX.p12(EX.O11, EX.O12) + + EX.S2 + |> EX.p2([EX.O21, EX.O22]) + + EX.p3(EX.S3, EX.O3) + end + + assert graph == + RDF.graph([ + EX.S1 |> EX.p11(EX.O11, EX.O12), + EX.S1 |> EX.p12(EX.O11, EX.O12), + EX.S2 |> EX.p2([EX.O21, EX.O22]), + EX.p3(EX.S3, EX.O3) + ]) + end + + test "triples given as tuples" do + graph = + RDF.Graph.build do + EX.S1 |> EX.p1(EX.O1) + + {EX.S2, EX.p2(), EX.O2} + end + + assert graph == + RDF.graph([ + EX.S1 |> EX.p1(EX.O1), + EX.S2 |> EX.p2(EX.O2) + ]) + end + + test "nested statements" do + graph = + RDF.Graph.build do + [ + EX.S1 |> EX.p1([EX.O11, EX.O12]), + [ + {EX.S2, EX.p2(), EX.O2}, + {EX.S31, EX.p31(), EX.O31} + ], + {EX.S32, EX.p32(), EX.O32} + ] + end + + assert graph == + RDF.graph([ + EX.S1 |> EX.p1([EX.O11, EX.O12]), + EX.S2 |> EX.p2(EX.O2), + {EX.S31, EX.p31(), EX.O31}, + {EX.S32, EX.p32(), EX.O32} + ]) + end + + test "a functions as shortcut for rdf:type" do + graph = + RDF.Graph.build do + EX.S1 |> a(EX.Class1) + EX.S2 |> a(EX.Class1, EX.Class1) + EX.S3 |> a(EX.Class1, EX.Class2, EX.Class3) + EX.S4 |> a(EX.Class1, EX.Class2, EX.Class3) + EX.S5 |> a(EX.Class1, EX.Class2, EX.Class3, EX.Class4) + EX.S5 |> a(EX.Class1, EX.Class2, EX.Class3, EX.Class4, EX.Class5) + {EX.S6, a(), EX.O2} + end + + assert graph == + RDF.graph([ + EX.S1 |> RDF.type(EX.Class1), + EX.S2 |> RDF.type(EX.Class1, EX.Class1), + EX.S3 |> RDF.type(EX.Class1, EX.Class2, EX.Class3), + EX.S4 |> RDF.type(EX.Class1, EX.Class2, EX.Class3), + EX.S5 |> RDF.type(EX.Class1, EX.Class2, EX.Class3, EX.Class4), + EX.S5 |> RDF.type(EX.Class1, EX.Class2, EX.Class3, EX.Class4, EX.Class5), + {EX.S6, RDF.type(), EX.O2} + ]) + end + + test "non-RDF interpretable data is ignored" do + assert_raise Builder.Error, "invalid RDF data: 42", fn -> + RDF.Graph.build do + EX.S |> EX.p(EX.O) + 42 + end + end + + assert_raise Builder.Error, "invalid RDF data: \"foo\"", fn -> + RDF.Graph.build do + EX.S |> EX.p(EX.O) + "foo" + end + end + + assert_raise Builder.Error, "invalid RDF data: {:ok, \"foo\"}", fn -> + RDF.Graph.build do + EX.S |> EX.p(EX.O) + {:ok, "foo"} + end + end + end + + test "variable assignments" do + graph = + RDF.Graph.build do + EX.S1 |> EX.p1(EX.O1) + literal = "foo" + EX.S2 |> EX.p2(literal) + end + + assert graph == + RDF.graph([ + EX.S1 |> EX.p1(EX.O1), + EX.S2 |> EX.p2("foo") + ]) + end + + test "function applications" do + graph = + RDF.Graph.build do + Enum.map(1..3, &{EX.S, EX.p(), &1}) + + Enum.map(1..2, fn i -> + RDF.iri("http://example.com/foo#{i}") + |> EX.bar(RDF.bnode("baz#{i}")) + end) + end + + assert graph == + RDF.graph([ + {EX.S, EX.p(), 1}, + {EX.S, EX.p(), 2}, + {EX.S, EX.p(), 3}, + {RDF.iri("http://example.com/foo1"), EX.bar(), RDF.bnode("baz1")}, + {RDF.iri("http://example.com/foo2"), EX.bar(), RDF.bnode("baz2")} + ]) + end + + test "conditionals" do + graph = + RDF.Graph.build do + foo = false + + cond do + true -> EX.S1 |> EX.p1(EX.O1) + end + + if foo do + EX.S2 |> EX.p2(EX.O2) + end + end + + assert graph == RDF.graph([EX.S1 |> EX.p1(EX.O1)]) + end + + test "comprehensions" do + graph = + RDF.Graph.build do + range = 1..3 + + for i <- range do + EX.S |> EX.p(i) + end + end + + assert graph == + RDF.graph([ + {EX.S, EX.p(), 1}, + {EX.S, EX.p(), 2}, + {EX.S, EX.p(), 3} + ]) + end + + test "RDF.Sigils is imported" do + # we're wrapping this in a function to isolate the import + graph = + (fn -> + RDF.Graph.build do + ~I"http://test/iri" |> EX.p(~B"foo") + end + end).() + + assert graph == RDF.graph(RDF.iri("http://test/iri") |> EX.p(RDF.bnode("foo"))) + end + + test "RDF.XSD is aliased" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + EX.S |> EX.p(XSD.byte(42)) + end + end).() + + assert graph == RDF.graph(EX.S |> EX.p(RDF.XSD.byte(42))) + end + + test "default aliases" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + OWL.Class |> RDFS.subClassOf(RDFS.Class) + end + end).() + + assert graph == RDF.graph(NS.OWL.Class |> NS.RDFS.subClassOf(NS.RDFS.Class)) + end + + test "alias" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + alias TestNS.Custom + # alias RDF.Graph.BuilderTest.TestNS.Custom + Custom.S |> Custom.p(Custom.O) + end + end).() + + assert graph == RDF.graph(TestNS.Custom.S |> TestNS.Custom.p(TestNS.Custom.O)) + end + + test "import" do + # we're wrapping this in a function to isolate the import + graph = + (fn -> + RDF.Graph.build do + import RDF.Graph.BuilderTest.TestNS.ImportTest + EX.S |> foo(RDF.Graph.BuilderTest.TestNS.ImportTest.Bar) + end + end).() + + assert graph == RDF.graph(EX.S |> TestNS.ImportTest.foo(TestNS.ImportTest.Bar)) + end + + test "require" do + {graph, log} = + with_log(fn -> + RDF.Graph.build do + require Logger + Logger.info("logged successfully") + EX.S |> EX.p(EX.O) + end + end) + + assert graph == RDF.graph(EX.S |> EX.p(EX.O)) + assert log =~ "logged successfully" + end + + test "use" do + graph = + RDF.Graph.build do + use UseTest + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O)) + end + + describe "@prefix" do + test "for vocabulary namespace with explicit prefix" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + # TODO: the following leads to a (RDF.Namespace.UndefinedTermError) Elixir.TestNS is not a RDF.Namespace + # @prefix custom: TestNS.Custom + @prefix cust: RDF.Graph.BuilderTest.TestNS.Custom + + Custom.S |> Custom.p(Custom.O) + end + end).() + + assert graph == + RDF.graph(TestNS.Custom.S |> TestNS.Custom.p(TestNS.Custom.O), + prefixes: RDF.default_prefixes(cust: TestNS.Custom) + ) + end + + test "for vocabulary namespace with auto-generated prefix" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + # TODO: the following leads to a (RDF.Namespace.UndefinedTermError) Elixir.TestNS is not a RDF.Namespace + # @prefix custom: TestNS.Custom + @prefix RDF.Graph.BuilderTest.TestNS.Custom + + Custom.S |> Custom.p(Custom.O) + end + end).() + + assert graph == + RDF.graph(TestNS.Custom.S |> TestNS.Custom.p(TestNS.Custom.O), + prefixes: RDF.default_prefixes(custom: TestNS.Custom) + ) + end + + test "merge with prefixes opt" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build prefixes: [custom: EX] do + # TODO: the following leads to a (RDF.Namespace.UndefinedTermError) Elixir.TestNS is not a RDF.Namespace + # @prefix custom: TestNS.Custom + @prefix custom: RDF.Graph.BuilderTest.TestNS.Custom + + Custom.S |> Custom.p(Custom.O) + end + end).() + + assert graph == + RDF.graph(TestNS.Custom.S |> TestNS.Custom.p(TestNS.Custom.O), + prefixes: [custom: TestNS.Custom] + ) + end + end + + describe "@base" do + test "with vocabulary namespace" do + # we're wrapping this in a function to isolate the alias + graph = + (fn -> + RDF.Graph.build do + # TODO: the following leads to a (RDF.Namespace.UndefinedTermError) Elixir.TestNS is not a RDF.Namespace + # @prefix custom: TestNS.Custom + @base RDF.Graph.BuilderTest.TestNS.Custom + + Custom.S |> Custom.p(Custom.O) + end + end).() + + assert graph == + RDF.graph(TestNS.Custom.S |> TestNS.Custom.p(TestNS.Custom.O), + base_iri: TestNS.Custom + ) + end + + test "with RDF.IRI" do + graph = + RDF.Graph.build do + @base ~I + + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O), base_iri: "http://example.com/base") + end + + test "with URI as string" do + graph = + RDF.Graph.build do + @base "http://example.com/base" + + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O), base_iri: "http://example.com/base") + end + + test "with URI from variable" do + graph = + RDF.Graph.build do + foo = "http://example.com/base" + @base foo + + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O), base_iri: "http://example.com/base") + end + + test "conflict with base_iri opt" do + graph = + RDF.Graph.build base_iri: "http://example.com/old" do + @base "http://example.com/base" + + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O), base_iri: "http://example.com/base") + end + end + + test "opts" do + initial = {EX.S, EX.p(), "init"} + + opts = [ + name: EX.Graph, + base_iri: "http://base_iri/", + prefixes: [ex: EX], + init: initial + ] + + graph = + RDF.Graph.build opts do + EX.S |> EX.p(EX.O) + end + + assert graph == RDF.graph(EX.S |> EX.p(EX.O, "init"), opts) + end +end