rdf-ex/lib/rdf/xsd/datatypes/date_time.ex
2020-06-29 10:37:42 +02:00

232 lines
7.1 KiB
Elixir

defmodule RDF.XSD.DateTime do
@moduledoc """
`RDF.XSD.Datatype` for XSD dateTimes.
"""
@type valid_value :: DateTime.t() | NaiveDateTime.t()
use RDF.XSD.Datatype.Primitive,
name: "dateTime",
id: RDF.Utils.Bootstrapping.xsd_iri("dateTime")
alias RDF.XSD
def_applicable_facet XSD.Facets.ExplicitTimezone
def_applicable_facet XSD.Facets.Pattern
@doc false
def explicit_timezone_conform?(:required, %DateTime{}, _), do: true
def explicit_timezone_conform?(:required, _, _), do: false
def explicit_timezone_conform?(:prohibited, %NaiveDateTime{}, _), do: true
def explicit_timezone_conform?(:prohibited, _, _), do: false
def explicit_timezone_conform?(:optional, _, _), do: true
@doc false
def pattern_conform?(pattern, _value, lexical) do
XSD.Facets.Pattern.conform?(pattern, lexical)
end
@impl XSD.Datatype
def lexical_mapping(lexical, opts) do
case DateTime.from_iso8601(lexical) do
{:ok, datetime, _} ->
elixir_mapping(datetime, opts)
{:error, :missing_offset} ->
case NaiveDateTime.from_iso8601(lexical) do
{:ok, datetime} -> elixir_mapping(datetime, opts)
_ -> @invalid_value
end
{:error, :invalid_format} ->
if String.ends_with?(lexical, "-00:00") do
lexical
|> String.replace_trailing("-00:00", "Z")
|> lexical_mapping(opts)
else
@invalid_value
end
{:error, :invalid_time} ->
if String.contains?(lexical, "T24:00:00") do
with [day, tz] <- String.split(lexical, "T24:00:00", parts: 2),
{:ok, day} <- Date.from_iso8601(day) do
lexical_mapping("#{day |> Date.add(1) |> Date.to_string()}T00:00:00#{tz}", opts)
else
_ -> @invalid_value
end
else
@invalid_value
end
_ ->
@invalid_value
end
end
@impl XSD.Datatype
@spec elixir_mapping(valid_value | any, Keyword.t()) :: value
def elixir_mapping(value, _)
# Special case for date and dateTime, for which 0 is not a valid year
def elixir_mapping(%DateTime{year: 0}, _), do: @invalid_value
def elixir_mapping(%DateTime{} = value, _), do: value
# Special case for date and dateTime, for which 0 is not a valid year
def elixir_mapping(%NaiveDateTime{year: 0}, _), do: @invalid_value
def elixir_mapping(%NaiveDateTime{} = value, _), do: value
def elixir_mapping(_, _), do: @invalid_value
@impl XSD.Datatype
@spec canonical_mapping(valid_value) :: String.t()
def canonical_mapping(value)
def canonical_mapping(%DateTime{} = value), do: DateTime.to_iso8601(value)
def canonical_mapping(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value)
@impl RDF.Literal.Datatype
def do_cast(value)
def do_cast(%XSD.String{} = xsd_string), do: new(xsd_string.value)
def do_cast(literal) do
cond do
XSD.Date.datatype?(literal) ->
case literal.value do
{value, zone} ->
(value |> XSD.Date.new() |> XSD.Date.canonical_lexical()) <> "T00:00:00" <> zone
value ->
(value |> XSD.Date.new() |> XSD.Date.canonical_lexical()) <> "T00:00:00"
end
|> new()
true ->
super(literal)
end
end
@doc """
Builds a `RDF.XSD.DateTime` literal for current moment in time.
"""
@spec now() :: RDF.Literal.t()
def now() do
new(DateTime.utc_now())
end
@doc """
Extracts the timezone string from a `RDF.XSD.DateTime` value.
"""
@spec tz(RDF.Literal.t() | t()) :: String.t() | nil
def tz(xsd_datetime)
def tz(%RDF.Literal{literal: xsd_datetime}), do: tz(xsd_datetime)
def tz(%__MODULE__{value: %NaiveDateTime{}}), do: ""
def tz(date_time_literal) do
if valid?(date_time_literal) do
date_time_literal
|> lexical()
|> XSD.Utils.DateTime.tz()
end
end
@doc """
Converts a datetime literal to a canonical string, preserving the zone information.
"""
@spec canonical_lexical_with_zone(RDF.Literal.t() | t()) :: String.t() | nil
def canonical_lexical_with_zone(%RDF.Literal{literal: xsd_datetime}),
do: canonical_lexical_with_zone(xsd_datetime)
def canonical_lexical_with_zone(%__MODULE__{} = xsd_datetime) do
case tz(xsd_datetime) do
nil ->
nil
zone when zone in ["Z", "", "+00:00", "-00:00"] ->
canonical_lexical(xsd_datetime)
zone ->
xsd_datetime
|> lexical()
|> String.replace_trailing(zone, "Z")
|> DateTime.from_iso8601()
|> elem(1)
|> new()
|> canonical_lexical()
|> String.replace_trailing("Z", zone)
end
end
@impl RDF.Literal.Datatype
def do_equal_value_same_or_derived_datatypes?(
%{value: %type{} = left_value},
%{value: %type{} = right_value}
) do
type.compare(left_value, right_value) == :eq
end
# This is another quirk for the open-world test date-2 from the SPARQL 1.0 test suite:
# comparisons between one date with tz and another one without a tz are incomparable
# when the unequal, but comparable and returning false when equal.
# What's the reasoning behind this madness?
def do_equal_value_same_or_derived_datatypes?(left_literal, right_literal) do
case compare(left_literal, right_literal) do
:lt -> false
:gt -> false
# This actually can't/shouldn't happen.
:eq -> true
_ -> nil
end
end
@impl RDF.Literal.Datatype
def do_equal_value_different_datatypes?(left, right) do
if XSD.Date.datatype?(left) or XSD.Date.datatype?(right) do
false
else
super(left, right)
end
end
@impl RDF.Literal.Datatype
def do_compare(left, right)
def do_compare(%{value: %type{} = value1}, %{value: %type{} = value2}) do
type.compare(value1, value2)
end
# It seems quite strange that open-world test date-2 from the SPARQL 1.0 test suite
# allows for equality comparisons between dates and datetimes, but disallows
# ordering comparisons in the date-3 test. The following implementation would allow
# an ordering comparisons between date and datetimes.
#
# def do_compare(%__MODULE__{} = literal1, %XSD.Date{} = literal2) do
# XSD.Date.do_compare(literal1, literal2)
# end
#
# def do_compare(%XSD.Date{} = literal1, %__MODULE__{} = literal2) do
# XSD.Date.do_compare(literal1, literal2)
# end
def do_compare(%{value: %DateTime{}} = left, %{value: %NaiveDateTime{} = right_value}) do
cond do
do_compare(left, new(to_datetime(right_value, "+")).literal) == :lt -> :lt
do_compare(left, new(to_datetime(right_value, "-")).literal) == :gt -> :gt
true -> :indeterminate
end
end
def do_compare(%{value: %NaiveDateTime{} = left}, %{value: %DateTime{}} = right_literal) do
cond do
do_compare(new(to_datetime(left, "-")).literal, right_literal) == :lt -> :lt
do_compare(new(to_datetime(left, "+")).literal, right_literal) == :gt -> :gt
true -> :indeterminate
end
end
def do_compare(_, _), do: nil
defp to_datetime(naive_datetime, offset) do
(NaiveDateTime.to_iso8601(naive_datetime) <> offset <> "14:00")
|> DateTime.from_iso8601()
|> elem(1)
end
end