From e5c8043cc2cb597a994028eaf0668a321e30d504 Mon Sep 17 00:00:00 2001 From: Marcel Otto Date: Sat, 18 Feb 2017 21:35:27 +0100 Subject: [PATCH] core: RDF.Dataset --- lib/rdf/dataset.ex | 515 +++++++++++++++++++++++++++++++++++++ lib/rdf/exceptions.ex | 8 + lib/rdf/graph.ex | 67 ++++- lib/rdf/quad.ex | 64 +++++ test/support/rdf_case.ex | 38 ++- test/unit/dataset_test.exs | 380 +++++++++++++++++++++++++++ test/unit/graph_test.exs | 9 - test/unit/quad_test.exs | 5 + 8 files changed, 1066 insertions(+), 20 deletions(-) create mode 100644 lib/rdf/dataset.ex create mode 100644 lib/rdf/quad.ex create mode 100644 test/unit/dataset_test.exs create mode 100644 test/unit/quad_test.exs diff --git a/lib/rdf/dataset.ex b/lib/rdf/dataset.ex new file mode 100644 index 0000000..61bfabd --- /dev/null +++ b/lib/rdf/dataset.ex @@ -0,0 +1,515 @@ +defmodule RDF.Dataset do + @moduledoc """ + Defines a RDF Dataset. + + A `RDF.Dataset` represents a set of `RDF.Dataset`s. + """ + defstruct name: nil, graphs: %{} + + @behaviour Access + + alias RDF.{Quad, Graph, Description} + + @type t :: module + + + @doc """ + Creates an empty unnamed `RDF.Dataset`. + """ + def new, + do: %RDF.Dataset{} + + @doc """ + Creates an unnamed `RDF.Dataset` with an initial statement. + """ + def new(statement) when is_tuple(statement), + do: new() |> add(statement) + + @doc """ + Creates an unnamed `RDF.Dataset` with initial statements. + """ + def new(statements) when is_list(statements), + do: new() |> add(statements) + + @doc """ + Creates an unnamed `RDF.Dataset` with a `RDF.Description`. + """ + def new(%RDF.Description{} = description), + do: new() |> add(description) + + @doc """ + Creates an empty named `RDF.Dataset`. + """ + def new(name), + do: %RDF.Dataset{name: RDF.uri(name)} + + @doc """ + Creates a named `RDF.Dataset` with an initial statement. + """ + def new(name, statement) when is_tuple(statement), + do: new(name) |> add(statement) + + @doc """ + Creates a named `RDF.Dataset` with initial statements. + """ + def new(name, statements) when is_list(statements), + do: new(name) |> add(statements) + + @doc """ + Creates a named `RDF.Dataset` with a `RDF.Description`. + """ + def new(name, %RDF.Description{} = description), + do: new(name) |> add(description) + + + + @doc """ + Adds triples and quads to a `RDF.Dataset`. + """ + def add(dataset, statements, graph_context \\ nil) + + def add(dataset, statements, graph_context) when is_list(statements) do + with graph_context = Quad.convert_graph_context(graph_context) do + Enum.reduce statements, dataset, fn (statement, dataset) -> + add(dataset, statement, graph_context) + end + end + end + + def add(dataset, {subject, predicate, objects}, graph_context), + do: add(dataset, {subject, predicate, objects, graph_context}) + + def add(%RDF.Dataset{name: name, graphs: graphs}, + {subject, predicate, objects, graph_context}, _) do + with graph_context = Quad.convert_graph_context(graph_context) do + updated_graphs = + Map.update(graphs, graph_context, + Graph.new(graph_context, {subject, predicate, objects}), + fn graph -> Graph.add(graph, {subject, predicate, objects}) end) + %RDF.Dataset{name: name, graphs: updated_graphs} + end + end + + def add(%RDF.Dataset{name: name, graphs: graphs}, + %Description{} = description, graph_context) do + with graph_context = Quad.convert_graph_context(graph_context) do + updated_graph = + Map.get(graphs, graph_context, Graph.new(graph_context)) + |> Graph.add(description) + %RDF.Dataset{ + name: name, + graphs: Map.put(graphs, graph_context, updated_graph) + } + end + end + + + @doc """ + Adds statements to a `RDF.Dataset` and overwrites all existing statements with the same subjects and predicates in the specified graph context. + + # Examples + + iex> dataset = RDF.Dataset.new({EX.S, EX.P1, EX.O1}) + ...> RDF.Dataset.put(dataset, {EX.S, EX.P1, EX.O2}) + RDF.Dataset.new({EX.S, EX.P1, EX.O2}) + iex> RDF.Dataset.put(dataset, {EX.S, EX.P2, EX.O2}) + RDF.Dataset.new([{EX.S, EX.P1, EX.O1}, {EX.S, EX.P2, EX.O2}]) + iex> RDF.Dataset.new([{EX.S1, EX.P1, EX.O1}, {EX.S2, EX.P2, EX.O2}]) |> + ...> RDF.Dataset.put([{EX.S1, EX.P2, EX.O3}, {EX.S2, EX.P2, EX.O3}]) + RDF.Dataset.new([{EX.S1, EX.P1, EX.O1}, {EX.S1, EX.P2, EX.O3}, {EX.S2, EX.P2, EX.O3}]) + + Note: When using a map to pass the statements you'll have to take care for yourselve to + avoid using subject key clashes due to using inconsistent, semantically equivalent forms. + + iex> RDF.Dataset.put(RDF.Dataset.new, %{ + ...> EX.S => [{EX.P, EX.O1}], + ...> {EX.S, nil} => [{EX.P, EX.O2}]}) + RDF.Dataset.new({EX.S, EX.P, EX.O2}) + + The last always always wins in these cases. This decision was made to mitigate + performance drawbacks. The list form will always take care of this for you: + + iex> RDF.Dataset.put(RDF.Dataset.new, [ + ...> {EX.S, EX.P, EX.O1}, + ...> {EX.S, EX.P, EX.O2, nil}]) + RDF.Dataset.new({EX.S, EX.P, EX.O1}, {EX.S, EX.P, EX.O2}) + """ + def put(dataset, statements, graph_context \\ nil) + + def put(%RDF.Dataset{} = dataset, {subject, predicate, objects}, graph_context), + do: put(dataset, {subject, predicate, objects, graph_context}) + + def put(%RDF.Dataset{name: name, graphs: graphs}, + {subject, predicate, objects, graph_context}, _) do + with graph_context = Quad.convert_graph_context(graph_context) do + new_graph = + case graphs[graph_context] do + graph = %Graph{} -> + Graph.put(graph, {subject, predicate, objects}) + nil -> + Graph.new(graph_context, {subject, predicate, objects}) + end + %RDF.Dataset{name: name, + graphs: Map.put(graphs, graph_context, new_graph)} + end + end + + def put(%RDF.Dataset{} = dataset, statements, graph_context) + when is_list(statements) do + with graph_context = Quad.convert_graph_context(graph_context) do + put dataset, Enum.group_by(statements, + fn + {s, _, _, nil} -> s + {s, _, _, c} -> {s, c} + {s, _, _} when is_nil(graph_context) -> s + {s, _, _} -> {s, graph_context} + end, + fn + {_, p, o, _} -> {p, o} + {_, p, o} -> {p, o} + end) + end + end + + def put(%RDF.Dataset{name: name, graphs: graphs}, + %Description{} = description, graph_context) do + with graph_context = Quad.convert_graph_context(graph_context) do + updated_graph = + Map.get(graphs, graph_context, Graph.new(graph_context)) + |> Graph.put(description) + %RDF.Dataset{ + name: name, + graphs: Map.put(graphs, graph_context, updated_graph) + } + end + end + + def put(%RDF.Dataset{} = dataset, statements, graph_context) + when is_map(statements) do + with graph_context = Quad.convert_graph_context(graph_context) do + Enum.reduce statements, dataset, + fn ({subject_with_context, predications}, dataset) -> + put(dataset, subject_with_context, predications, graph_context) + end + end + end + + def put(%RDF.Dataset{name: name, graphs: graphs}, + {subject, graph_context}, predications, default_graph_context) + when is_list(predications) do + with graph_context = graph_context || default_graph_context, + graph_context = Quad.convert_graph_context(graph_context) do + graph = Map.get(graphs, graph_context, Graph.new(graph_context)) + new_graphs = graphs + |> Map.put(graph_context, Graph.put(graph, subject, predications)) + %RDF.Dataset{name: name, graphs: new_graphs} + end + end + + def put(%RDF.Dataset{} = dataset, subject, predications, graph_context) + when is_list(predications), + do: put(dataset, {subject, graph_context}, predications, graph_context) + + + + @doc """ + Fetches the `RDF.Graph` with the given name. + + When a graph with the given name can not be found can not be found `:error` is returned. + + # Examples + + iex> dataset = RDF.Dataset.new([{EX.S1, EX.P1, EX.O1, EX.Graph}, {EX.S2, EX.P2, EX.O2}]) + ...> RDF.Dataset.fetch(dataset, EX.Graph) + {:ok, RDF.Graph.new(EX.Graph, {EX.S1, EX.P1, EX.O1})} + iex> RDF.Dataset.fetch(dataset, nil) + {:ok, RDF.Graph.new({EX.S2, EX.P2, EX.O2})} + iex> RDF.Dataset.fetch(dataset, EX.Foo) + :error + """ + def fetch(%RDF.Dataset{graphs: graphs}, graph_name) do + Access.fetch(graphs, Quad.convert_graph_context(graph_name)) + end + + @doc """ + Fetches the `RDF.Graph` with the given name. + + When a graph with the given name can not be found can not be found the optionally + given default value or `nil` is returned + + # Examples + + iex> dataset = RDF.Dataset.new([{EX.S1, EX.P1, EX.O1, EX.Graph}, {EX.S2, EX.P2, EX.O2}]) + ...> RDF.Dataset.get(dataset, EX.Graph) + RDF.Graph.new(EX.Graph, {EX.S1, EX.P1, EX.O1}) + iex> RDF.Dataset.get(dataset, nil) + RDF.Graph.new({EX.S2, EX.P2, EX.O2}) + iex> RDF.Dataset.get(dataset, EX.Foo) + nil + iex> RDF.Dataset.get(dataset, EX.Foo, :bar) + :bar + """ + def get(%RDF.Dataset{} = dataset, graph_name, default \\ nil) do + case fetch(dataset, graph_name) do + {:ok, value} -> value + :error -> default + end + end + + @doc """ + The default graph of a `RDF.Dataset`. + """ + def default_graph(%RDF.Dataset{graphs: graphs}), + do: Map.get(graphs, nil, Graph.new) + + + @doc """ + Gets and updates the graph with the given name, in a single pass. + + Invokes the passed function on the `RDF.Graph` with the given name; + this function should return either `{graph_to_return, new_graph}` or `:pop`. + + If the passed function returns `{graph_to_return, new_graph}`, the + return value of `get_and_update` is `{graph_to_return, new_dataset}` where + `new_dataset` is the input `Dataset` updated with `new_graph` for + the given name. + + If the passed function returns `:pop` the graph with the given name is + removed and a `{removed_graph, new_dataset}` tuple gets returned. + + # Examples + + iex> dataset = RDF.Dataset.new({EX.S, EX.P, EX.O, EX.Graph}) + ...> RDF.Dataset.get_and_update(dataset, EX.Graph, fn current_graph -> + ...> {current_graph, {EX.S, EX.P, EX.NEW}} + ...> end) + {RDF.Graph.new(EX.Graph, {EX.S, EX.P, EX.O}), RDF.Dataset.new({EX.S, EX.P, EX.NEW, EX.Graph})} + """ + def get_and_update(%RDF.Dataset{} = dataset, graph_name, fun) do + with graph_context = Quad.convert_graph_context(graph_name) do + case fun.(get(dataset, graph_context)) do + {old_graph, new_graph} -> + {old_graph, put(dataset, new_graph, graph_context)} + :pop -> + pop(dataset, graph_context) + other -> + raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}" + end + end + end + + @doc """ + Pops the graph with the given name. + + When a graph with given name can not be found the optionally given default value + or `nil` is returned. + + # Examples + + iex> dataset = RDF.Dataset.new([ + ...> {EX.S1, EX.P1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.P2, EX.O2}]) + ...> RDF.Dataset.pop(dataset, EX.Graph) + {RDF.Graph.new(EX.Graph, {EX.S1, EX.P1, EX.O1}), RDF.Dataset.new({EX.S2, EX.P2, EX.O2})} + iex> RDF.Dataset.pop(dataset, EX.Foo) + {nil, dataset} + """ + def pop(%RDF.Dataset{name: name, graphs: graphs} = dataset, graph_name) do + case Access.pop(graphs, Quad.convert_graph_context(graph_name)) do + {nil, _} -> + {nil, dataset} + {graph, new_graphs} -> + {graph, %RDF.Dataset{name: name, graphs: new_graphs}} + end + end + + + + @doc """ + The number of statements within a `RDF.Dataset`. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Dataset.statement_count + 3 + """ + def statement_count(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, 0, fn ({_, graph}, count) -> + count + Graph.triple_count(graph) + end + end + + @doc """ + The set of all subjects used in the statement within all graphs of a `RDF.Dataset`. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Dataset.subjects + MapSet.new([RDF.uri(EX.S1), RDF.uri(EX.S2)]) + """ + def subjects(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, MapSet.new, fn ({_, graph}, subjects) -> + MapSet.union(subjects, Graph.subjects(graph)) + end + end + + @doc """ + The set of all properties used in the predicates within all graphs of a `RDF.Dataset`. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Dataset.predicates + MapSet.new([EX.p1, EX.p2]) + """ + def predicates(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, MapSet.new, fn ({_, graph}, predicates) -> + MapSet.union(predicates, Graph.predicates(graph)) + end + end + + @doc """ + The set of all resources used in the objects within a `RDF.Dataset`. + + Note: This function does collect only URIs and BlankNodes, not Literals. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2, EX.Graph}, + ...> {EX.S3, EX.p1, EX.O2}, + ...> {EX.S4, EX.p2, RDF.bnode(:bnode)}, + ...> {EX.S5, EX.p3, "foo"} + ...> ]) |> RDF.Dataset.objects + MapSet.new([RDF.uri(EX.O1), RDF.uri(EX.O2), RDF.bnode(:bnode)]) + """ + def objects(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, MapSet.new, fn ({_, graph}, objects) -> + MapSet.union(objects, Graph.objects(graph)) + end + end + + @doc """ + The set of all resources used within a `RDF.Dataset`. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p1, EX.O2, EX.Graph}, + ...> {EX.S2, EX.p2, RDF.bnode(:bnode)}, + ...> {EX.S3, EX.p1, "foo"} + ...> ]) |> RDF.Dataset.resources + MapSet.new([RDF.uri(EX.S1), RDF.uri(EX.S2), RDF.uri(EX.S3), + RDF.uri(EX.O1), RDF.uri(EX.O2), RDF.bnode(:bnode), EX.p1, EX.p2]) + """ + def resources(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, MapSet.new, fn ({_, graph}, resources) -> + MapSet.union(resources, Graph.resources(graph)) + end + end + + @doc """ + All statements within all graphs of a `RDF.Dataset`. + + # Examples + + iex> RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Dataset.statements + [{RDF.uri(EX.S1), RDF.uri(EX.p1), RDF.uri(EX.O1), RDF.uri(EX.Graph)}, + {RDF.uri(EX.S1), RDF.uri(EX.p2), RDF.uri(EX.O3)}, + {RDF.uri(EX.S2), RDF.uri(EX.p2), RDF.uri(EX.O2)}] + """ + def statements(%RDF.Dataset{graphs: graphs}) do + Enum.reduce graphs, [], fn ({_, graph}, all_statements) -> + statements = Graph.triples(graph) + if graph.name do + Enum.map statements, fn {s, p, o} -> {s, p, o, graph.name} end + else + statements + end ++ all_statements + end + end + + + @doc """ + Returns if a given statement is in a `RDF.Dataset`. + + # Examples + + iex> dataset = RDF.Dataset.new([ + ...> {EX.S1, EX.p1, EX.O1, EX.Graph}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) + ...> RDF.Dataset.include?(dataset, {EX.S1, EX.p1, EX.O1, EX.Graph}) + true + """ + def include?(dataset, statement, graph_context \\ nil) + + def include?(%RDF.Dataset{graphs: graphs}, triple = {_, _, _}, graph_context) do + with graph_context = Quad.convert_graph_context(graph_context) do + if graph = graphs[graph_context] do + Graph.include?(graph, triple) + else + false + end + end + end + + def include?(%RDF.Dataset{} = description, + {subject, predicate, object, graph_context}, _), + do: include?(description, {subject, predicate, object}, graph_context) + + + # TODO: Can/should we isolate and move the Enumerable specific part to the Enumerable implementation? + + def reduce(%RDF.Dataset{graphs: graphs}, {:cont, acc}, _fun) + when map_size(graphs) == 0, do: {:done, acc} + + def reduce(%RDF.Dataset{} = dataset, {:cont, acc}, fun) do + {statement, rest} = RDF.Dataset.pop(dataset) + reduce(rest, fun.(statement, acc), fun) + end + + def reduce(_, {:halt, acc}, _fun), do: {:halted, acc} + def reduce(dataset = %RDF.Dataset{}, {:suspend, acc}, fun) do + {:suspended, acc, &reduce(dataset, &1, fun)} + end + + + def pop(%RDF.Dataset{graphs: graphs} = dataset) + when graphs == %{}, do: {nil, dataset} + + def pop(%RDF.Dataset{name: name, graphs: graphs}) do +# # TODO: Find a faster way ... + [{graph_name, graph}] = Enum.take(graphs, 1) + {{s, p, o}, popped_graph} = Graph.pop(graph) + popped = if Enum.empty?(popped_graph), + do: graphs |> Map.delete(graph_name), + else: graphs |> Map.put(graph_name, popped_graph) + + {{s, p, o, graph_name}, %RDF.Dataset{name: name, graphs: popped}} + end + +end + +defimpl Enumerable, for: RDF.Dataset do + def reduce(graph, acc, fun), do: RDF.Dataset.reduce(graph, acc, fun) + def member?(graph, statement), do: {:ok, RDF.Dataset.include?(graph, statement)} + def count(graph), do: {:ok, RDF.Dataset.statement_count(graph)} +end diff --git a/lib/rdf/exceptions.ex b/lib/rdf/exceptions.ex index 47214d9..58cfd6d 100644 --- a/lib/rdf/exceptions.ex +++ b/lib/rdf/exceptions.ex @@ -22,6 +22,14 @@ defmodule RDF.Triple.InvalidPredicateError do end end +defmodule RDF.Quad.InvalidGraphContextError do + defexception [:graph_context] + + def message(%{graph_context: graph_context}) do + "'#{inspect(graph_context)}' is not a valid graph context of a RDF.Quad" + end +end + defmodule RDF.Vocabulary.InvalidBaseURIError do defexception [:message] diff --git a/lib/rdf/graph.ex b/lib/rdf/graph.ex index b6b58df..f2cb71f 100644 --- a/lib/rdf/graph.ex +++ b/lib/rdf/graph.ex @@ -33,11 +33,17 @@ defmodule RDF.Graph do do: new() |> add(triples) @doc """ - Creates an unnamed `RDF.Graph` with an `RDF.Description`. + Creates an unnamed `RDF.Graph` with a `RDF.Description`. """ def new(%RDF.Description{} = description), do: new() |> add(description) + @doc """ + Creates an empty unnamed `RDF.Graph`. + """ + def new(nil), + do: new() + @doc """ Creates an empty named `RDF.Graph`. """ @@ -57,7 +63,7 @@ defmodule RDF.Graph do do: new(name) |> add(triples) @doc """ - Creates a named `RDF.Graph` with an `RDF.Description`. + Creates a named `RDF.Graph` with a `RDF.Description`. """ def new(name, %RDF.Description{} = description), do: new(name) |> add(description) @@ -178,11 +184,10 @@ defmodule RDF.Graph do def put(%RDF.Graph{name: name, descriptions: descriptions}, subject, predications) when is_list(predications) do - with triple_subject = Triple.convert_subject(subject), - description = Map.get(descriptions, triple_subject) - || Description.new(triple_subject) do + with subject = Triple.convert_subject(subject) do + description = Map.get(descriptions, subject, Description.new(subject)) new_descriptions = descriptions - |> Map.put(triple_subject, Description.put(description, predications)) + |> Map.put(subject, Description.put(description, predications)) %RDF.Graph{name: name, descriptions: new_descriptions} end end @@ -257,7 +262,10 @@ defmodule RDF.Graph do case fun.(get(graph, triple_subject)) do {old_description, new_description} -> {old_description, put(graph, triple_subject, new_description)} - :pop -> pop(graph, triple_subject) + :pop -> + pop(graph, triple_subject) + other -> + raise "the given function must return a two-element tuple or :pop, got: #{inspect(other)}" end end end @@ -285,8 +293,33 @@ defmodule RDF.Graph do end - def subject_count(graph), do: Enum.count(graph.descriptions) + @doc """ + The number of subjects within a `RDF.Graph`. + # Examples + + iex> RDF.Graph.new([ + ...> {EX.S1, EX.p1, EX.O1}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Graph.subject_count + 2 + """ + def subject_count(%RDF.Graph{descriptions: descriptions}), + do: Enum.count(descriptions) + + @doc """ + The number of statements within a `RDF.Graph`. + + # Examples + + iex> RDF.Graph.new([ + ...> {EX.S1, EX.p1, EX.O1}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3}]) |> + ...> RDF.Graph.triple_count + 3 + """ def triple_count(%RDF.Graph{descriptions: descriptions}) do Enum.reduce descriptions, 0, fn ({_subject, description}, count) -> count + Description.count(description) @@ -294,7 +327,7 @@ defmodule RDF.Graph do end @doc """ - The set of all properties used in the predicates within a `RDF.Graph`. + The set of all subjects used in the statements within a `RDF.Graph`. # Examples @@ -309,7 +342,7 @@ defmodule RDF.Graph do do: descriptions |> Map.keys |> MapSet.new @doc """ - The set of all properties used in the predicates within a `RDF.Graph`. + The set of all properties used in the predicates of the statements within a `RDF.Graph`. # Examples @@ -374,6 +407,20 @@ defmodule RDF.Graph do end) |> MapSet.union(subjects(graph)) end + @doc """ + All statements within a `RDF.Graph`. + + # Examples + + iex> RDF.Graph.new([ + ...> {EX.S1, EX.p1, EX.O1}, + ...> {EX.S2, EX.p2, EX.O2}, + ...> {EX.S1, EX.p2, EX.O3} + ...> ]) |> RDF.Graph.triples + [{RDF.uri(EX.S1), RDF.uri(EX.p1), RDF.uri(EX.O1)}, + {RDF.uri(EX.S1), RDF.uri(EX.p2), RDF.uri(EX.O3)}, + {RDF.uri(EX.S2), RDF.uri(EX.p2), RDF.uri(EX.O2)}] + """ def triples(graph = %RDF.Graph{}), do: Enum.to_list(graph) def include?(%RDF.Graph{descriptions: descriptions}, diff --git a/lib/rdf/quad.ex b/lib/rdf/quad.ex new file mode 100644 index 0000000..a52b819 --- /dev/null +++ b/lib/rdf/quad.ex @@ -0,0 +1,64 @@ +defmodule RDF.Quad do + @moduledoc """ + Defines a RDF Quad. + + A Quad is a plain Elixir tuple consisting of four valid RDF values for + subject, predicate, object and a graph context. + """ + + alias RDF.{BlankNode, Literal} + + import RDF.Triple, except: [new: 1, new: 3] + + @type graph_context :: URI.t | BlankNode.t + @type convertible_graph_context :: graph_context | atom | String.t + + @doc """ + Creates a `RDF.Quad` with proper RDF values. + + An error is raised when the given elements are not convertible to RDF values. + + Note: The `RDF.quad` function is a shortcut to this function. + + # Examples + + iex> RDF.Quad.new("http://example.com/S", "http://example.com/p", 42, "http://example.com/Graph") + {RDF.uri("http://example.com/S"), RDF.uri("http://example.com/p"), RDF.literal(42), RDF.uri("http://example.com/Graph")} + """ + def new(subject, predicate, object, graph_context) do + { + convert_subject(subject), + convert_predicate(predicate), + convert_object(object), + convert_graph_context(graph_context) + } + end + + @doc """ + Creates a `RDF.Quad` with proper RDF values. + + An error is raised when the given elements are not convertible to RDF values. + + Note: The `RDF.quad` function is a shortcut to this function. + + # Examples + + iex> RDF.Quad.new {"http://example.com/S", "http://example.com/p", 42, "http://example.com/Graph"} + {RDF.uri("http://example.com/S"), RDF.uri("http://example.com/p"), RDF.literal(42), RDF.uri("http://example.com/Graph")} + """ + def new({subject, predicate, object, graph_context}), + do: new(subject, predicate, object, graph_context) + + + @doc false + def convert_graph_context(uri) + def convert_graph_context(nil), do: nil + def convert_graph_context(uri = %URI{}), do: uri + def convert_graph_context(bnode = %BlankNode{}), do: bnode + def convert_graph_context(uri) when is_atom(uri) or is_binary(uri), + do: RDF.uri(uri) + def convert_graph_context(arg), + do: raise RDF.Quad.InvalidGraphContextError, graph_context: arg + + +end diff --git a/test/support/rdf_case.ex b/test/support/rdf_case.ex index 1db23f6..05287f9 100644 --- a/test/support/rdf_case.ex +++ b/test/support/rdf_case.ex @@ -57,10 +57,46 @@ defmodule RDF.Test.Case do def empty_graph?(%Graph{descriptions: descriptions}), do: descriptions == %{} - def graph_includes_statement?(graph, statement = {subject, _, _}) do + def graph_includes_statement?(graph, {subject, _, _} = statement) do graph.descriptions |> Map.get(uri(subject), %{}) |> Enum.member?(statement) end + + ############################### + # RDF.Graph + + def dataset, do: unnamed_dataset() + + def unnamed_dataset, do: Dataset.new + + def named_dataset(name \\ EX.GraphName), do: Dataset.new(name) + + def unnamed_dataset?(%Dataset{name: nil}), do: true + def unnamed_dataset?(_), do: false + + def named_dataset?(%Dataset{name: %URI{}}), do: true + def named_dataset?(_), do: false + def named_dataset?(%Dataset{name: name}, name), do: true + def named_dataset?(_, _), do: false + + def empty_dataset?(%Dataset{graphs: graphs}), do: graphs == %{} + + def dataset_includes_statement?(dataset, {_, _, _} = statement) do + dataset + |> Dataset.default_graph + |> graph_includes_statement?(statement) + end + + def dataset_includes_statement?(dataset, {subject, predicate, objects, nil}), + do: dataset_includes_statement?(dataset, {subject, predicate, objects}) + + def dataset_includes_statement?(dataset, + {subject, predicate, objects, graph_context}) do + dataset.graphs + |> Map.get(uri(graph_context), named_graph(graph_context)) + |> graph_includes_statement?({subject, predicate, objects}) + end + end diff --git a/test/unit/dataset_test.exs b/test/unit/dataset_test.exs new file mode 100644 index 0000000..9786569 --- /dev/null +++ b/test/unit/dataset_test.exs @@ -0,0 +1,380 @@ +defmodule RDF.DatasetTest do + use RDF.Test.Case + + doctest RDF.Dataset + + + describe "construction" do + test "creating an empty unnamed dataset" do + assert unnamed_dataset?(unnamed_dataset()) + end + + test "creating an empty dataset with a proper dataset name" do + refute unnamed_dataset?(named_dataset()) + assert named_dataset?(named_dataset()) + end + + test "creating an empty dataset with a convertible dataset name" do + assert named_dataset("http://example.com/DatasetName") + |> named_dataset?(uri("http://example.com/DatasetName")) + assert named_dataset(EX.Foo) |> named_dataset?(uri(EX.Foo)) + end + + test "creating an unnamed dataset with an initial triple" do + ds = Dataset.new({EX.Subject, EX.predicate, EX.Object}) + assert unnamed_dataset?(ds) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object}) + end + + test "creating an unnamed dataset with an initial quad" do + ds = Dataset.new({EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + assert unnamed_dataset?(ds) + assert dataset_includes_statement?(ds, + {EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + end + + test "creating a named dataset with an initial triple" do + ds = Dataset.new(EX.DatasetName, {EX.Subject, EX.predicate, EX.Object}) + assert named_dataset?(ds, uri(EX.DatasetName)) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object}) + end + + test "creating a named dataset with an initial quad" do + ds = Dataset.new(EX.DatasetName, {EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + assert named_dataset?(ds, uri(EX.DatasetName)) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + end + + test "creating an unnamed dataset with a list of initial statements" do + ds = Dataset.new([ + {EX.Subject1, EX.predicate1, EX.Object1}, + {EX.Subject2, EX.predicate2, EX.Object2, EX.GraphName}, + {EX.Subject3, EX.predicate3, EX.Object3, nil} + ]) + assert unnamed_dataset?(ds) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1, nil}) + assert dataset_includes_statement?(ds, {EX.Subject2, EX.predicate2, EX.Object2, EX.GraphName}) + assert dataset_includes_statement?(ds, {EX.Subject3, EX.predicate3, EX.Object3, nil}) + end + + test "creating a named dataset with a list of initial statements" do + ds = Dataset.new(EX.DatasetName, [ + {EX.Subject, EX.predicate1, EX.Object1}, + {EX.Subject, EX.predicate2, EX.Object2, EX.GraphName}, + {EX.Subject, EX.predicate3, EX.Object3, nil} + ]) + assert named_dataset?(ds, uri(EX.DatasetName)) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate1, EX.Object1, nil}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate2, EX.Object2, EX.GraphName}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate3, EX.Object3, nil}) + end + + test "creating a named dataset with an initial description" do + ds = Dataset.new(EX.GraphName, Description.new({EX.Subject, EX.predicate, EX.Object})) + assert named_dataset?(ds, uri(EX.GraphName)) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object}) + end + + test "creating an unnamed dataset with an initial description" do + ds = Dataset.new(Description.new({EX.Subject, EX.predicate, EX.Object})) + assert unnamed_dataset?(ds) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object}) + end + end + + describe "adding statements" do + test "a proper triple is added to the default graph" do + assert Dataset.add(dataset(), {uri(EX.Subject), EX.predicate, uri(EX.Object)}) + |> dataset_includes_statement?({EX.Subject, EX.predicate, EX.Object}) + end + + test "a proper quad is added to the specified graph" do + ds = Dataset.add(dataset(), {uri(EX.Subject), EX.predicate, uri(EX.Object), uri(EX.Graph)}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object, uri(EX.Graph)}) + end + + test "a proper quad with nil context is added to the default graph" do + ds = Dataset.add(dataset(), {uri(EX.Subject), EX.predicate, uri(EX.Object), nil}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate, EX.Object}) + end + + + test "a convertible triple" do + assert Dataset.add(dataset(), + {"http://example.com/Subject", EX.predicate, EX.Object}) + |> dataset_includes_statement?({EX.Subject, EX.predicate, EX.Object}) + end + + test "a convertible quad" do + assert Dataset.add(dataset(), + {"http://example.com/Subject", EX.predicate, EX.Object, "http://example.com/GraphName"}) + |> dataset_includes_statement?({EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + end + + test "statements with multiple objects" do + ds = Dataset.add(dataset(), {EX.Subject1, EX.predicate1, [EX.Object1, EX.Object2]}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object2}) + + ds = Dataset.add(dataset(), {EX.Subject1, EX.predicate1, [EX.Object1, EX.Object2], EX.GraphName}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1, EX.GraphName}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object2, EX.GraphName}) + end + + test "a list of triples without specification the default context" do + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1}, + {EX.Subject1, EX.predicate2, EX.Object2}, + {EX.Subject3, EX.predicate3, EX.Object3} + ]) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate2, EX.Object2}) + assert dataset_includes_statement?(ds, {EX.Subject3, EX.predicate3, EX.Object3}) + end + + test "a list of triples with specification the default context" do + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1}, + {EX.Subject1, EX.predicate2, EX.Object2}, + {EX.Subject3, EX.predicate3, EX.Object3, nil} + ], EX.Graph) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate2, EX.Object2, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.Subject3, EX.predicate3, EX.Object3, nil}) + end + + test "a list of quads" do + ds = Dataset.add(dataset(), [ + {EX.Subject, EX.predicate1, EX.Object1, EX.Graph1}, + {EX.Subject, EX.predicate2, EX.Object2, nil}, + {EX.Subject, EX.predicate1, EX.Object1, EX.Graph2} + ]) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate1, EX.Object1, EX.Graph1}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate2, EX.Object2, nil}) + assert dataset_includes_statement?(ds, {EX.Subject, EX.predicate1, EX.Object1, EX.Graph2}) + end + + test "a list of mixed triples and quads" do + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1, EX.GraphName}, + {EX.Subject3, EX.predicate3, EX.Object3} + ]) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1, EX.GraphName}) + assert dataset_includes_statement?(ds, {EX.Subject3, EX.predicate3, EX.Object3, nil}) + end + + test "a Description" do + ds = Dataset.add(dataset(), Description.new(EX.Subject1, [ + {EX.predicate1, EX.Object1}, + {EX.predicate2, EX.Object2}, + ])) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate2, EX.Object2}) + + ds = Dataset.add(ds, Description.new({EX.Subject1, EX.predicate3, EX.Object3}), EX.Graph) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate2, EX.Object2}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate3, EX.Object3, EX.Graph}) + end + + @tag skip: "TODO" + test "an unnamed Graph" do + ds = Dataset.add(dataset(), Graph.new(%{EX.Subject1 => [ + {EX.predicate1, EX.Object1}, + {EX.predicate2, EX.Object2}, + ]})) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate2, EX.Object2}) + end + + @tag skip: "TODO" + test "a named Graph" do + end + + test "a list of Descriptions" do + ds = Dataset.add(dataset(), [ + Description.new({EX.Subject1, EX.predicate1, EX.Object1}), + Description.new({EX.Subject2, EX.predicate2, EX.Object2}), + Description.new({EX.Subject1, EX.predicate3, EX.Object3}) + ]) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject2, EX.predicate2, EX.Object2}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate3, EX.Object3}) + + ds = Dataset.add(ds, [ + Description.new({EX.Subject1, EX.predicate1, EX.Object1}), + Description.new({EX.Subject2, EX.predicate2, EX.Object2}), + Description.new({EX.Subject1, EX.predicate3, EX.Object3}) + ], EX.Graph) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.Subject2, EX.predicate2, EX.Object2, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate3, EX.Object3, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate1, EX.Object1}) + assert dataset_includes_statement?(ds, {EX.Subject2, EX.predicate2, EX.Object2}) + assert dataset_includes_statement?(ds, {EX.Subject1, EX.predicate3, EX.Object3}) + end + + @tag skip: "TODO" + test "a list of Graphs" do + end + + test "duplicates are ignored" do + ds = Dataset.add(dataset(), {EX.Subject, EX.predicate, EX.Object, EX.GraphName}) + assert Dataset.add(ds, {EX.Subject, EX.predicate, EX.Object, EX.GraphName}) == ds + end + + test "non-convertible statements elements are causing an error" do + assert_raise RDF.InvalidURIError, fn -> + Dataset.add(dataset(), {"not a URI", EX.predicate, uri(EX.Object), uri(EX.GraphName)}) + end + assert_raise RDF.InvalidLiteralError, fn -> + Dataset.add(dataset(), {EX.Subject, EX.prop, self(), nil}) + end + assert_raise RDF.InvalidURIError, fn -> + Dataset.add(dataset(), {uri(EX.Subject), EX.predicate, uri(EX.Object), "not a URI"}) + end + end + end + + describe "putting triples" do + test "a list of triples" do + ds = Dataset.new([{EX.S1, EX.P1, EX.O1}, {EX.S2, EX.P2, EX.O2, EX.Graph}]) + |> RDF.Dataset.put([ + {EX.S1, EX.P2, EX.O3, EX.Graph}, + {EX.S1, EX.P2, bnode(:foo), nil}, + {EX.S2, EX.P2, EX.O3, EX.Graph}, + {EX.S2, EX.P2, EX.O4, EX.Graph}]) + + assert Dataset.statement_count(ds) == 5 + assert dataset_includes_statement?(ds, {EX.S1, EX.P1, EX.O1}) + assert dataset_includes_statement?(ds, {EX.S1, EX.P2, EX.O3, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.S1, EX.P2, bnode(:foo)}) + assert dataset_includes_statement?(ds, {EX.S2, EX.P2, EX.O3, EX.Graph}) + assert dataset_includes_statement?(ds, {EX.S2, EX.P2, EX.O4, EX.Graph}) + end + + test "a Description" do + ds = Dataset.new([{EX.S1, EX.P1, EX.O1}, {EX.S2, EX.P2, EX.O2}, {EX.S1, EX.P3, EX.O3}]) + |> RDF.Dataset.put(Description.new(EX.S1, [{EX.P3, EX.O4}, {EX.P2, bnode(:foo)}])) + + assert Dataset.statement_count(ds) == 4 + assert dataset_includes_statement?(ds, {EX.S1, EX.P1, EX.O1}) + assert dataset_includes_statement?(ds, {EX.S1, EX.P3, EX.O4}) + assert dataset_includes_statement?(ds, {EX.S1, EX.P2, bnode(:foo)}) + assert dataset_includes_statement?(ds, {EX.S2, EX.P2, EX.O2}) + end + + @tag skip: "TODO" + test "an unnamed Graph" do + end + + @tag skip: "TODO" + test "a named Graph" do + end + +# @tag skip: "TODO: Requires Dataset.put with a list to differentiate a list of statements, a list of Descriptions and list of Graphs. Do we want to support mixed lists also?" +# test "a list of Descriptions" do +# ds = Dataset.new([{EX.S1, EX.P1, EX.O1}, {EX.S2, EX.P2, EX.O2}]) +# |> RDF.Dataset.put([ +# Description.new(EX.S1, [{EX.P2, EX.O3}, {EX.P2, bnode(:foo)}]), +# Description.new(EX.S2, [{EX.P2, EX.O3}, {EX.P2, EX.O4}]) +# ]) +# +# assert Dataset.triple_count(ds) == 5 +# assert dataset_includes_statement?(ds, {EX.S1, EX.P1, EX.O1}) +# assert dataset_includes_statement?(ds, {EX.S1, EX.P2, EX.O3}) +# assert dataset_includes_statement?(ds, {EX.S1, EX.P2, bnode(:foo)}) +# assert dataset_includes_statement?(ds, {EX.S2, EX.P2, EX.O3}) +# assert dataset_includes_statement?(ds, {EX.S2, EX.P2, EX.O4}) +# end + + test "simultaneous use of the different forms to address the default context" do + ds = RDF.Dataset.put(dataset(), [ + {EX.S, EX.P, EX.O1}, + {EX.S, EX.P, EX.O2, nil}]) + assert Dataset.statement_count(ds) == 2 + assert dataset_includes_statement?(ds, {EX.S, EX.P, EX.O1}) + assert dataset_includes_statement?(ds, {EX.S, EX.P, EX.O2}) + +# TODO: see comment on RDF.Dataset.put on why the following is not supported +# ds = RDF.Dataset.put(dataset(), %{ +# EX.S => [{EX.P, EX.O1}], +# {EX.S, nil} => [{EX.P, EX.O2}] +# }) +# assert Dataset.statement_count(ds) == 2 +# assert dataset_includes_statement?(ds, {EX.S, EX.P, EX.O1}) +# assert dataset_includes_statement?(ds, {EX.S, EX.P, EX.O2}) + end + end + + test "pop a statement" do + assert Dataset.pop(Dataset.new) == {nil, Dataset.new} + + {quad, dataset} = Dataset.new({EX.S, EX.p, EX.O, EX.Graph}) |> Dataset.pop + assert quad == {uri(EX.S), uri(EX.p), uri(EX.O), uri(EX.Graph)} + assert Enum.count(dataset.graphs) == 0 + + {{subject, predicate, object, _}, dataset} = + Dataset.new([{EX.S, EX.p, EX.O, EX.Graph}, {EX.S, EX.p, EX.O}]) + |> Dataset.pop + assert {subject, predicate, object} == {uri(EX.S), uri(EX.p), uri(EX.O)} + assert Enum.count(dataset.graphs) == 1 + + {{subject, _, _, graph_context}, dataset} = + Dataset.new([{EX.S, EX.p, EX.O1, EX.Graph}, {EX.S, EX.p, EX.O2, EX.Graph}]) + |> Dataset.pop + assert subject == uri(EX.S) + assert graph_context == uri(EX.Graph) + assert Enum.count(dataset.graphs) == 1 + end + + describe "Enumerable protocol" do + test "Enum.count" do + assert Enum.count(Dataset.new EX.foo) == 0 + assert Enum.count(Dataset.new {EX.S, EX.p, EX.O, EX.Graph}) == 1 + assert Enum.count(Dataset.new [{EX.S, EX.p, EX.O1, EX.Graph}, {EX.S, EX.p, EX.O2}]) == 2 + + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}, + {EX.Subject1, EX.predicate2, EX.Object2, EX.Graph}, + {EX.Subject3, EX.predicate3, EX.Object3} + ]) + assert Enum.count(ds) == 3 + end + + test "Enum.member?" do + refute Enum.member?(Dataset.new, {uri(EX.S), EX.p, uri(EX.O), uri(EX.Graph)}) + assert Enum.member?(Dataset.new({EX.S, EX.p, EX.O, EX.Graph}), + {EX.S, EX.p, EX.O, EX.Graph}) + + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}, + {EX.Subject1, EX.predicate2, EX.Object2, EX.Graph}, + {EX.Subject3, EX.predicate3, EX.Object3} + ]) + assert Enum.member?(ds, {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}) + assert Enum.member?(ds, {EX.Subject1, EX.predicate2, EX.Object2, EX.Graph}) + assert Enum.member?(ds, {EX.Subject3, EX.predicate3, EX.Object3}) + end + + test "Enum.reduce" do + ds = Dataset.add(dataset(), [ + {EX.Subject1, EX.predicate1, EX.Object1, EX.Graph}, + {EX.Subject1, EX.predicate2, EX.Object2}, + {EX.Subject3, EX.predicate3, EX.Object3, EX.Graph} + ]) + + assert ds == Enum.reduce(ds, dataset(), + fn(statement, acc) -> acc |> Dataset.add(statement) end) + end + end + + describe "Access behaviour" do + test "access with the [] operator" do + assert Dataset.new[EX.Graph] == nil + assert Dataset.new({EX.S, EX.p, EX.O, EX.Graph})[EX.Graph] == + Graph.new(EX.Graph, {EX.S, EX.p, EX.O}) + end + end + +end diff --git a/test/unit/graph_test.exs b/test/unit/graph_test.exs index a56707e..a25bd6d 100644 --- a/test/unit/graph_test.exs +++ b/test/unit/graph_test.exs @@ -195,15 +195,6 @@ defmodule RDF.GraphTest do end end - test "subject_count" do - g = Graph.add(graph(), [ - {EX.Subject1, EX.predicate1, EX.Object1}, - {EX.Subject1, EX.predicate2, EX.Object2}, - {EX.Subject3, EX.predicate3, EX.Object3} - ]) - assert Graph.subject_count(g) == 2 - end - test "pop a triple" do assert Graph.pop(Graph.new) == {nil, Graph.new} diff --git a/test/unit/quad_test.exs b/test/unit/quad_test.exs new file mode 100644 index 0000000..4330f01 --- /dev/null +++ b/test/unit/quad_test.exs @@ -0,0 +1,5 @@ +defmodule RDF.QuadTest do + use ExUnit.Case + + doctest RDF.Quad +end