Add ProtocolEx-based custom datatype registration

This commit is contained in:
Marcel Otto 2020-05-07 15:37:21 +02:00
parent 4fedb2cfc0
commit faaebb2de2
33 changed files with 182 additions and 60 deletions

View file

@ -209,10 +209,7 @@ defmodule RDF do
defdelegate literal(value), to: Literal, as: :new
defdelegate literal(value, opts), to: Literal, as: :new
def literal?(%Literal{}), do: true
def literal?(%RDF.Literal.Generic{}), do: true
def literal?(%datatype{}), do: Literal.Datatype.Registry.datatype?(datatype)
def literal?(_), do: false
defdelegate literal?(literal), to: Literal.Datatype.Registry
defdelegate triple(s, p, o), to: Triple, as: :new
defdelegate triple(tuple), to: Triple, as: :new

View file

@ -92,12 +92,6 @@ defmodule RDF.Literal do
def coerce(%__MODULE__{} = literal), do: literal
Enum.each(Datatype.Registry.datatypes(), fn datatype ->
def coerce(%unquote(datatype){} = literal) do
%__MODULE__{literal: literal}
end
end)
def coerce(value) when is_binary(value), do: RDF.XSD.String.new(value)
def coerce(value) when is_boolean(value), do: RDF.XSD.Boolean.new(value)
def coerce(value) when is_integer(value), do: RDF.XSD.Integer.new(value)
@ -108,7 +102,22 @@ defmodule RDF.Literal do
def coerce(%DateTime{} = value), do: RDF.XSD.DateTime.new(value)
def coerce(%NaiveDateTime{} = value), do: RDF.XSD.DateTime.new(value)
def coerce(%URI{} = value), do: RDF.XSD.AnyURI.new(value)
def coerce(_), do: nil
# Although the following catch-all-clause for all structs could handle the core datatypes
# we're generating dedicated clauses for them here, as they are approx. 15% faster
Enum.each(Datatype.Registry.core_datatypes(), fn datatype ->
def coerce(%unquote(datatype){} = datatype_literal) do
%__MODULE__{literal: datatype_literal}
end
end)
def coerce(%_datatype{} = datatype_literal) do
if Datatype.Registry.literal?(datatype_literal) do
%__MODULE__{literal: datatype_literal}
end
end
def coerce(_), do: nil
@doc """

View file

@ -140,6 +140,17 @@ defmodule RDF.Literal.Datatype do
defmacro __using__(opts) do
name = Keyword.fetch!(opts, :name)
id = Keyword.fetch!(opts, :id)
do_register = Keyword.get(opts, :register, not is_nil(id))
datatype = __CALLER__.module
# TODO: find an alternative to Code.eval_quoted - We want to support that id can be passed via a function call
unquoted_id =
if do_register do
id
|> Code.eval_quoted([], __ENV__)
|> elem(0)
|> to_string()
end
quote do
@behaviour unquote(__MODULE__)
@ -258,6 +269,15 @@ defmodule RDF.Literal.Datatype do
literal.__struct__.lexical(literal)
end
end
if unquote(do_register) do
import ProtocolEx
defimpl_ex Registration, unquote(unquoted_id),
for: RDF.Literal.Datatype.Registry.Registration do
def datatype(id), do: unquote(datatype)
end
end
end
end
end

View file

@ -1,33 +1,45 @@
# TODO: This registry should be managed automatically/dynamically and be extendable, to allow user-defined datatypes ...
defmodule RDF.Literal.Datatype.Registry do
@moduledoc false
alias RDF.{Literal, IRI, XSD}
alias RDF.Literal.Datatype.Registry.Registration
@datatypes [RDF.LangString | Enum.to_list(XSD.datatypes())]
import RDF.Guards
@mapping Map.new(@datatypes, fn datatype -> {IRI.new(datatype.id), datatype} end)
@core_datatypes [RDF.LangString | Enum.to_list(XSD.datatypes())]
@mapping Map.new(@core_datatypes, fn datatype -> {IRI.new(datatype.id), datatype} end)
@doc """
The mapping of IRIs of datatypes to their `RDF.Literal.Datatype`.
The IRIs of all core `RDF.Literal.Datatype`s.
"""
@spec mapping :: %{IRI.t => Literal.Datatype.t}
def mapping, do: @mapping
@spec core_ids :: [IRI.t]
def core_ids, do: Map.keys(@mapping)
@doc """
The IRIs of all datatypes with a `RDF.Literal.Datatype` defined.
All core `RDF.Literal.Datatype` modules.
"""
@spec ids :: [IRI.t]
def ids, do: Map.keys(@mapping)
@spec core_datatypes :: Enum.t
def core_datatypes, do: @core_datatypes
@doc """
All defined `RDF.Literal.Datatype` modules.
Checks if the given module is core datatype.
"""
@spec datatypes :: Enum.t
def datatypes, do: @datatypes
@spec core_datatype?(module) :: boolean
def core_datatype?(module), do: module in @core_datatypes
@doc """
Checks if the given module is a core datatype or a registered custom datatype implementing the `RDF.Literal.Datatype` behaviour.
"""
@spec datatype?(module) :: boolean
def datatype?(module), do: module in @datatypes
def datatype?(module) do
core_datatype?(module) or implements_datatype_behaviour?(module)
end
@spec literal?(module) :: boolean
def literal?(%Literal{}), do: true
def literal?(%Literal.Generic{}), do: true
def literal?(%datatype{}), do: datatype?(datatype)
def literal?(_), do: false
@doc """
Returns the `RDF.Literal.Datatype` for a directly datatype IRI or the datatype IRI of a `RDF.Literal`.
@ -35,5 +47,19 @@ defmodule RDF.Literal.Datatype.Registry do
@spec get(Literal.t | IRI.t | String.t) :: Literal.Datatype.t
def get(%Literal{} = literal), do: Literal.datatype(literal)
def get(id) when is_binary(id), do: id |> IRI.new() |> get()
def get(id), do: @mapping[id]
def get(id) when maybe_ns_term(id), do: id |> IRI.new() |> get()
def get(id), do: @mapping[id] || get_custom_datatype(id)
defp get_custom_datatype(id) do
id
|> to_string()
|> Registration.datatype()
end
defp implements_datatype_behaviour?(module) do
module.module_info[:attributes]
|> Keyword.get_values(:behaviour)
|> List.flatten()
|> Enum.member?(RDF.Literal.Datatype)
end
end

View file

@ -0,0 +1,5 @@
import ProtocolEx
defprotocol_ex RDF.Literal.Datatype.Registry.Registration do
def datatype(id), do: nil
end

View file

@ -7,7 +7,8 @@ defmodule RDF.LangString do
use RDF.Literal.Datatype,
name: "langString",
id: RDF.Utils.Bootstrapping.rdf_iri("langString")
id: RDF.Utils.Bootstrapping.rdf_iri("langString"),
register: false # core datatypes don't need to be registered
alias RDF.Literal.Datatype
alias RDF.Literal

View file

@ -11,7 +11,8 @@ defmodule RDF.XSD.AnyURI do
use RDF.XSD.Datatype.Primitive,
name: "anyURI",
id: RDF.Utils.Bootstrapping.xsd_iri("anyURI")
id: RDF.Utils.Bootstrapping.xsd_iri("anyURI"),
register: false # core datatypes don't need to be registered
@impl RDF.XSD.Datatype
@spec lexical_mapping(String.t(), Keyword.t()) :: valid_value

View file

@ -8,7 +8,8 @@ defmodule RDF.XSD.Boolean do
use RDF.XSD.Datatype.Primitive,
name: "boolean",
id: RDF.Utils.Bootstrapping.xsd_iri("boolean")
id: RDF.Utils.Bootstrapping.xsd_iri("boolean"),
register: false # core datatypes don't need to be registered
@impl RDF.XSD.Datatype
def lexical_mapping(lexical, _) do

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.Byte do
use RDF.XSD.Datatype.Restriction,
name: "byte",
id: RDF.Utils.Bootstrapping.xsd_iri("byte"),
base: RDF.XSD.Short
base: RDF.XSD.Short,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, -128
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 127

View file

@ -11,7 +11,8 @@ defmodule RDF.XSD.Date do
use RDF.XSD.Datatype.Primitive,
name: "date",
id: RDF.Utils.Bootstrapping.xsd_iri("date")
id: RDF.Utils.Bootstrapping.xsd_iri("date"),
register: false # core datatypes don't need to be registered
# TODO: Are GMT/UTC actually allowed? Maybe because it is supported by Elixir's Datetime ...

View file

@ -7,7 +7,8 @@ defmodule RDF.XSD.DateTime do
use RDF.XSD.Datatype.Primitive,
name: "dateTime",
id: RDF.Utils.Bootstrapping.xsd_iri("dateTime")
id: RDF.Utils.Bootstrapping.xsd_iri("dateTime"),
register: false # core datatypes don't need to be registered
@impl RDF.XSD.Datatype
def lexical_mapping(lexical, opts) do

View file

@ -7,7 +7,8 @@ defmodule RDF.XSD.Decimal do
use RDF.XSD.Datatype.Primitive,
name: "decimal",
id: RDF.Utils.Bootstrapping.xsd_iri("decimal")
id: RDF.Utils.Bootstrapping.xsd_iri("decimal"),
register: false # core datatypes don't need to be registered
alias Elixir.Decimal, as: D

View file

@ -8,7 +8,8 @@ defmodule RDF.XSD.Double do
use RDF.XSD.Datatype.Primitive,
name: "double",
id: RDF.Utils.Bootstrapping.xsd_iri("double")
id: RDF.Utils.Bootstrapping.xsd_iri("double"),
register: false # core datatypes don't need to be registered
@special_values ~W[positive_infinity negative_infinity nan]a

View file

@ -9,5 +9,6 @@ defmodule RDF.XSD.Float do
use RDF.XSD.Datatype.Restriction,
name: "float",
id: RDF.Utils.Bootstrapping.xsd_iri("float"),
base: RDF.XSD.Double
base: RDF.XSD.Double,
register: false # core datatypes don't need to be registered
end

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.Int do
use RDF.XSD.Datatype.Restriction,
name: "int",
id: RDF.Utils.Bootstrapping.xsd_iri("int"),
base: RDF.XSD.Long
base: RDF.XSD.Long,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, -2_147_483_648
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 2_147_483_647

View file

@ -10,7 +10,8 @@ defmodule RDF.XSD.Integer do
use RDF.XSD.Datatype.Primitive,
name: "integer",
id: RDF.Utils.Bootstrapping.xsd_iri("integer")
id: RDF.Utils.Bootstrapping.xsd_iri("integer"),
register: false # core datatypes don't need to be registered
def_applicable_facet RDF.XSD.Facets.MinInclusive
def_applicable_facet RDF.XSD.Facets.MaxInclusive

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.Long do
use RDF.XSD.Datatype.Restriction,
name: "long",
id: RDF.Utils.Bootstrapping.xsd_iri("long"),
base: RDF.XSD.Integer
base: RDF.XSD.Integer,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, -9_223_372_036_854_775_808
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 9_223_372_036_854_775_807

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.NegativeInteger do
use RDF.XSD.Datatype.Restriction,
name: "negativeInteger",
id: RDF.Utils.Bootstrapping.xsd_iri("negativeInteger"),
base: RDF.XSD.NonPositiveInteger
base: RDF.XSD.NonPositiveInteger,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MaxInclusive, -1
end

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.NonNegativeInteger do
use RDF.XSD.Datatype.Restriction,
name: "nonNegativeInteger",
id: RDF.Utils.Bootstrapping.xsd_iri("nonNegativeInteger"),
base: RDF.XSD.Integer
base: RDF.XSD.Integer,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 0
end

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.NonPositiveInteger do
use RDF.XSD.Datatype.Restriction,
name: "nonPositiveInteger",
id: RDF.Utils.Bootstrapping.xsd_iri("nonPositiveInteger"),
base: RDF.XSD.Integer
base: RDF.XSD.Integer,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 0
end

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.PositiveInteger do
use RDF.XSD.Datatype.Restriction,
name: "positiveInteger",
id: RDF.Utils.Bootstrapping.xsd_iri("positiveInteger"),
base: RDF.XSD.NonNegativeInteger
base: RDF.XSD.NonNegativeInteger,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 1
end

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.Short do
use RDF.XSD.Datatype.Restriction,
name: "short",
id: RDF.Utils.Bootstrapping.xsd_iri("short"),
base: RDF.XSD.Int
base: RDF.XSD.Int,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, -32768
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 32767

View file

@ -7,7 +7,8 @@ defmodule RDF.XSD.String do
use RDF.XSD.Datatype.Primitive,
name: "string",
id: RDF.Utils.Bootstrapping.xsd_iri("string")
id: RDF.Utils.Bootstrapping.xsd_iri("string"),
register: false # core datatypes don't need to be registered
@impl RDF.XSD.Datatype
@spec lexical_mapping(String.t(), Keyword.t()) :: valid_value

View file

@ -7,7 +7,8 @@ defmodule RDF.XSD.Time do
use RDF.XSD.Datatype.Primitive,
name: "time",
id: RDF.Utils.Bootstrapping.xsd_iri("time")
id: RDF.Utils.Bootstrapping.xsd_iri("time"),
register: false # core datatypes don't need to be registered
# TODO: Are GMT/UTC actually allowed? Maybe because it is supported by Elixir's Datetime ...
@grammar ~r/\A(\d{2}:\d{2}:\d{2}(?:\.\d+)?)((?:[\+\-]\d{2}:\d{2})|UTC|GMT|Z)?\Z/

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.UnsignedByte do
use RDF.XSD.Datatype.Restriction,
name: "unsignedByte",
id: RDF.Utils.Bootstrapping.xsd_iri("unsignedByte"),
base: RDF.XSD.UnsignedShort
base: RDF.XSD.UnsignedShort,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 0
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 255

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.UnsignedInt do
use RDF.XSD.Datatype.Restriction,
name: "unsignedInt",
id: RDF.Utils.Bootstrapping.xsd_iri("unsignedInt"),
base: RDF.XSD.UnsignedLong
base: RDF.XSD.UnsignedLong,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 0
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 4_294_967_295

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.UnsignedLong do
use RDF.XSD.Datatype.Restriction,
name: "unsignedLong",
id: RDF.Utils.Bootstrapping.xsd_iri("unsignedLong"),
base: RDF.XSD.NonNegativeInteger
base: RDF.XSD.NonNegativeInteger,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 0
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 18_446_744_073_709_551_615

View file

@ -2,7 +2,8 @@ defmodule RDF.XSD.UnsignedShort do
use RDF.XSD.Datatype.Restriction,
name: "unsignedShort",
id: RDF.Utils.Bootstrapping.xsd_iri("unsignedShort"),
base: RDF.XSD.UnsignedInt
base: RDF.XSD.UnsignedInt,
register: false # core datatypes don't need to be registered
def_facet_constraint RDF.XSD.Facets.MinInclusive, 0
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 65535

View file

@ -14,6 +14,7 @@ defmodule RDF.Mixfile do
start_permanent: Mix.env == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
compilers: Mix.compilers ++ [:protocol_ex],
# Dialyzer
dialyzer: dialyzer(),
@ -68,6 +69,7 @@ defmodule RDF.Mixfile do
defp deps do
[
{:decimal, "~> 1.5"},
{:protocol_ex, "~> 0.4"},
{:credo, "~> 1.3", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0.0-rc.7", only: :dev, runtime: false},

View file

@ -20,6 +20,7 @@
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"protocol_ex": {:hex, :protocol_ex, "0.4.3", "4acbe35da85109dc40315c1139bb7a65ebc7fc102d384cd8b3038384fbb9b282", [:mix], [], "hexpm", "6ca5ddb3505c9c86f17cd3f19838b34bf89966ae17078f79f81983b6a4391fe9"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"},
}

View file

@ -0,0 +1,10 @@
defmodule RDF.TestDatatypes do
defmodule Age do
use RDF.XSD.Datatype.Restriction,
name: "age",
id: "http://example.com/Age",
base: RDF.XSD.PositiveInteger
def_facet_constraint RDF.XSD.Facets.MaxInclusive, 150
end
end

View file

@ -1,6 +1,7 @@
defmodule RDF.Literal.Datatype.RegistryTest do
use ExUnit.Case
use RDF.Test.Case
alias RDF.TestDatatypes.Age
alias RDF.Literal.Datatype
alias RDF.NS
@ -36,20 +37,40 @@ defmodule RDF.Literal.Datatype.RegistryTest do
describe "get/1" do
test "IRIs of supported datatypes from the XSD namespace" do
Enum.each(@supported_xsd_datatypes, fn xsd_datatype_iri ->
assert xsd_datatype = Datatype.Registry.get(xsd_datatype_iri)
assert xsd_datatype == Datatype.Registry.get(to_string(xsd_datatype_iri))
assert RDF.iri(xsd_datatype.id) == xsd_datatype_iri
test "core datatypes" do
Enum.each(Datatype.Registry.core_datatypes(), fn datatype ->
assert datatype == Datatype.Registry.get(datatype.id)
assert datatype == Datatype.Registry.get(to_string(datatype.id))
end)
end
test "IRIs of unsupported datatypes from the XSD namespace" do
test "supported datatypes from the XSD namespace" do
Enum.each(@supported_xsd_datatypes, fn xsd_datatype_iri ->
assert xsd_datatype = Datatype.Registry.get(xsd_datatype_iri)
assert xsd_datatype.id == xsd_datatype_iri
end)
end
test "unsupported datatypes from the XSD namespace" do
Enum.each(@unsupported_xsd_datatypes, fn xsd_datatype_iri ->
refute Datatype.Registry.get(xsd_datatype_iri)
refute Datatype.Registry.get(to_string(xsd_datatype_iri))
end)
end
test "with IRI of custom datatype" do
assert Age == Datatype.Registry.get(Age.id)
end
test "with namespace terms" do
assert Age == Datatype.Registry.get(EX.Age)
end
end
test "core datatypes are handled differently and should not be registered (for performance reasons)" do
Enum.each(Datatype.Registry.core_datatypes(), fn datatype ->
refute Datatype.Registry.Registration.datatype(datatype.id)
refute Datatype.Registry.Registration.datatype(to_string(datatype.id))
end)
end
end

View file

@ -1,7 +1,6 @@
defmodule RDF.LiteralTest do
use ExUnit.Case
use RDF.Test.Case
import RDF.Sigils
import RDF.TestLiterals
alias RDF.{Literal, XSD, LangString}
@ -31,13 +30,18 @@ defmodule RDF.LiteralTest do
end
end
test "with typed literals" do
Enum.each Datatype.Registry.datatypes(), fn datatype ->
test "with core datatype literals" do
Enum.each Datatype.Registry.core_datatypes(), fn datatype ->
datatype_literal = datatype.new("foo").literal
assert %Literal{literal: ^datatype_literal} = Literal.new(datatype_literal)
end
end
test "with custom datatype literals" do
datatype_literal = RDF.TestDatatypes.Age.new(42).literal
assert %Literal{literal: typed_literal} = Literal.new(datatype_literal)
end
test "when options without datatype given" do
assert Literal.new(true, []) == XSD.Boolean.new(true)
assert Literal.new(42, []) == XSD.Integer.new(42)
@ -80,6 +84,10 @@ defmodule RDF.LiteralTest do
assert Literal.new("foo", datatype: NS.XSD.string) == XSD.String.new("foo")
end
test "registered custom datatype" do
assert Literal.new(42, datatype: EX.Age) == RDF.TestDatatypes.Age.new(42)
end
test "unmapped/unknown datatype" do
assert Literal.new("custom typed value", datatype: "http://example/dt") ==
Generic.new("custom typed value", datatype: "http://example/dt")