diff --git a/lib/elixir/docs.exs b/lib/elixir/docs.exs index a7ab34b9c47..49dc02a4b79 100644 --- a/lib/elixir/docs.exs +++ b/lib/elixir/docs.exs @@ -12,7 +12,6 @@ Base, Bitwise, Calendar, - Calendar.ISO, Date, DateTime, Exception, @@ -52,6 +51,11 @@ StringIO, System ], + "Calendar": [ + Calendar.ISO, + Calendar.TimeZoneDatabase, + Calendar.UTCOnlyTimeZoneDatabase + ], "Modules & Code": [ Code, Kernel.ParallelCompiler, diff --git a/lib/elixir/lib/calendar.ex b/lib/elixir/lib/calendar.ex index a156269c3c2..5dbf330c7c9 100644 --- a/lib/elixir/lib/calendar.ex +++ b/lib/elixir/lib/calendar.ex @@ -90,6 +90,28 @@ defmodule Calendar do microsecond: microsecond } + @typedoc """ + Specifies the time zone database for calendar operations. + + Many functions in the `DateTime` module require a time zone database. + By default, it uses the default time zone database returned by + `Calendar.get_time_zone_database/0`, which defaults to + `Calendar.UTCOnlyTimeZoneDatabase` which only handles "Etc/UTC" + datetimes and returns `{:error, :utc_only_time_zone_database}` + for any other time zone. + + Other time zone databases (including ones provided by packages) + can be configure as default either via configuration: + + config :elixir, :time_zone_database, CustomTimeZoneDatabase + + or by calling `Calendar.put_time_zone_database/1`. + + See `Calendar.TimeZoneDatabase` for more information on custom + time zone databases. + """ + @type time_zone_database :: module() + @doc """ Returns how many days there are in the given year-month. """ @@ -236,4 +258,22 @@ defmodule Calendar do end def truncate(_, :second), do: {0, 0} + + @doc """ + Sets the currente time zone database. + """ + @doc since: "1.8.0" + @spec put_time_zone_database(time_zone_database()) :: :ok + def put_time_zone_database(database) do + Application.put_env(:elixir, :time_zone_database, database) + end + + @doc """ + Gets the current time zone database. + """ + @doc since: "1.8.0" + @spec get_time_zone_database() :: time_zone_database() + def get_time_zone_database() do + Application.get_env(:elixir, :time_zone_database, Calendar.UTCOnlyTimeZoneDatabase) + end end diff --git a/lib/elixir/lib/calendar/datetime.ex b/lib/elixir/lib/calendar/datetime.ex index fcea57bc578..c33b16a5711 100644 --- a/lib/elixir/lib/calendar/datetime.ex +++ b/lib/elixir/lib/calendar/datetime.ex @@ -16,17 +16,21 @@ defmodule DateTime do and instead rely on the functions provided by this module as well as the ones in third-party calendar libraries. - ## Where are my functions? + ## Time zone database - You will notice this module only contains conversion - functions as well as functions that work on UTC. This - is because a proper `DateTime` implementation requires a - time zone database which currently is not provided as part - of Elixir. + Many functions in this module require a time zone database. + By default, it uses the default time zone database returned by + `Calendar.get_time_zone_database/0`, which defaults to + `Calendar.UTCOnlyTimeZoneDatabase` which only handles "Etc/UTC" + datetimes and returns `{:error, :utc_only_time_zone_database}` + for any other time zone. - Such may be addressed in upcoming versions, meanwhile, - use third-party packages to provide `DateTime` building and - similar functionality with time zone backing. + Other time zone databases (including ones provided by packages) + can be configure as default either via configuration: + + config :elixir, :time_zone_database, CustomTimeZoneDatabase + + or by calling `Calendar.put_time_zone_database/1`. """ @enforce_keys [:year, :month, :day, :hour, :minute, :second] ++ @@ -171,8 +175,11 @@ defmodule DateTime do @doc """ Converts the given `NaiveDateTime` to `DateTime`. - It expects a time zone to put the NaiveDateTime in. - Currently it only supports "Etc/UTC" as time zone. + It expects a time zone to put the `NaiveDateTime` in. + If the timezone is "Etc/UTC", it always succeeds. Otherwise, + the NaiveDateTime is checked against the time zone database + given as `time_zone_database`. See the "Time zone database" + section in the module documentation. ## Examples @@ -180,12 +187,118 @@ defmodule DateTime do iex> datetime #DateTime<2016-05-24 13:26:08.003Z> + When the datetime is ambiguous - for instance during changing from summer + to winter time - the two possible valid datetimes are returned. First the one + that happens first, then the one that happens after. + + iex> {:ambiguous, first_dt, second_dt} = DateTime.from_naive(~N[2018-10-28 02:30:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + iex> first_dt + #DateTime<2018-10-28 02:30:00+02:00 CEST Europe/Copenhagen> + iex> second_dt + #DateTime<2018-10-28 02:30:00+01:00 CET Europe/Copenhagen> + + When there is a gap in wall time - for instance in spring when the clocks are + turned forward - the latest valid datetime just before the gap and the first + valid datetime just after the gap. + + iex> {:gap, just_before, just_after} = DateTime.from_naive(~N[2019-03-31 02:30:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + iex> just_before + #DateTime<2019-03-31 01:59:59.999999+01:00 CET Europe/Copenhagen> + iex> just_after + #DateTime<2019-03-31 03:00:00+02:00 CEST Europe/Copenhagen> + + Most of the time there is one, and just one, valid datetime for a certain + date and time in a certain time zone. + + iex> {:ok, datetime} = DateTime.from_naive(~N[2018-07-28 12:30:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + iex> datetime + #DateTime<2018-07-28 12:30:00+02:00 CEST Europe/Copenhagen> + """ @doc since: "1.4.0" - @spec from_naive(NaiveDateTime.t(), Calendar.time_zone()) :: {:ok, t} - def from_naive(naive_datetime, time_zone) + @spec from_naive( + NaiveDateTime.t(), + Calendar.time_zone(), + Calendar.get_time_zone_database() + ) :: + {:ok, t} + | {:ambiguous, t, t} + | {:gap, t, t} + | {:error, + :incompatible_calendars | :time_zone_not_found | :utc_only_time_zone_database} + + def from_naive( + naive_datetime, + time_zone, + time_zone_database \\ Calendar.get_time_zone_database() + ) + + def from_naive(naive_datetime, "Etc/UTC", _) do + utc_period = %{std_offset: 0, utc_offset: 0, zone_abbr: "UTC"} + {:ok, from_naive_with_period(naive_datetime, "Etc/UTC", utc_period)} + end + + def from_naive(%{calendar: Calendar.ISO} = naive_datetime, time_zone, time_zone_database) do + case time_zone_database.time_zone_periods_from_wall_datetime(naive_datetime, time_zone) do + {:ok, period} -> + {:ok, from_naive_with_period(naive_datetime, time_zone, period)} + + {:ambiguous, first_period, second_period} -> + first_datetime = from_naive_with_period(naive_datetime, time_zone, first_period) + second_datetime = from_naive_with_period(naive_datetime, time_zone, second_period) + {:ambiguous, first_datetime, second_datetime} + + {:gap, {first_period, first_period_until_wall}, {second_period, second_period_from_wall}} -> + # `until_wall` is not valid, but any time just before is. + # So by subtracting a second and adding .999999 seconds + # we get the last microsecond just before. + before_naive = + first_period_until_wall + |> Map.put(:microsecond, {999_999, 6}) + |> NaiveDateTime.add(-1) + + after_naive = second_period_from_wall + + latest_datetime_before = from_naive_with_period(before_naive, time_zone, first_period) + first_datetime_after = from_naive_with_period(after_naive, time_zone, second_period) + {:gap, latest_datetime_before, first_datetime_after} + + {:error, _} = error -> + error + end + end + + def from_naive(%{calendar: calendar} = naive_datetime, time_zone, time_zone_database) + when calendar != Calendar.ISO do + # For non-ISO calendars, convert to ISO, create ISO DateTime, and then + # convert to original calendar + iso_result = + with {:ok, in_iso} <- NaiveDateTime.convert(naive_datetime, Calendar.ISO) do + from_naive(in_iso, time_zone, time_zone_database) + end + + case iso_result do + {:ok, dt} -> + convert(dt, calendar) + + {:ambiguous, dt1, dt2} -> + with {:ok, dt1converted} <- convert(dt1, calendar), + {:ok, dt2converted} <- convert(dt2, calendar), + do: {:ambiguous, dt1converted, dt2converted} + + {:gap, dt1, dt2} -> + with {:ok, dt1converted} <- convert(dt1, calendar), + {:ok, dt2converted} <- convert(dt2, calendar), + do: {:gap, dt1converted, dt2converted} + + {:error, _} = error -> + error + end + end + + defp from_naive_with_period(naive_datetime, time_zone, period) do + %{std_offset: std_offset, utc_offset: utc_offset, zone_abbr: zone_abbr} = period - def from_naive(%NaiveDateTime{} = naive_datetime, "Etc/UTC") do %{ calendar: calendar, hour: hour, @@ -197,7 +310,7 @@ defmodule DateTime do day: day } = naive_datetime - datetime = %DateTime{ + %DateTime{ calendar: calendar, year: year, month: month, @@ -206,40 +319,167 @@ defmodule DateTime do minute: minute, second: second, microsecond: microsecond, - std_offset: 0, - utc_offset: 0, - zone_abbr: "UTC", - time_zone: "Etc/UTC" + std_offset: std_offset, + utc_offset: utc_offset, + zone_abbr: zone_abbr, + time_zone: time_zone } - - {:ok, datetime} end @doc """ Converts the given `NaiveDateTime` to `DateTime`. It expects a time zone to put the NaiveDateTime in. - Currently it only supports "Etc/UTC" as time zone. + If the timezone is "Etc/UTC", it always succeeds. Otherwise, + the NaiveDateTime is checked against the time zone database + given as `time_zone_database`. See the "Time zone database" + section in the module documentation. ## Examples iex> DateTime.from_naive!(~N[2016-05-24 13:26:08.003], "Etc/UTC") #DateTime<2016-05-24 13:26:08.003Z> + iex> DateTime.from_naive!(~N[2018-05-24 13:26:08.003], "Europe/Copenhagen", FakeTimeZoneDatabase) + #DateTime<2018-05-24 13:26:08.003+02:00 CEST Europe/Copenhagen> + """ @doc since: "1.4.0" - @spec from_naive!(NaiveDateTime.t(), Calendar.time_zone()) :: t - def from_naive!(naive_datetime, time_zone) do - case from_naive(naive_datetime, time_zone) do + @spec from_naive!( + NaiveDateTime.t(), + Calendar.time_zone(), + Calendar.get_time_zone_database() + ) :: t + def from_naive!( + naive_datetime, + time_zone, + time_zone_database \\ Calendar.get_time_zone_database() + ) do + case from_naive(naive_datetime, time_zone, time_zone_database) do {:ok, datetime} -> datetime + {:ambiguous, dt1, dt2} -> + raise ArgumentError, + "cannot convert #{inspect(naive_datetime)} to datetime because such " <> + "instant is ambiguous in time zone #{time_zone} as there is an overlap " <> + "between #{inspect(dt1)} and #{inspect(dt2)}" + + {:gap, dt1, dt2} -> + raise ArgumentError, + "cannot convert #{inspect(naive_datetime)} to datetime because such " <> + "instant does not exist in time zone #{time_zone} as there is a gap " <> + "between #{inspect(dt1)} and #{inspect(dt2)}" + {:error, reason} -> raise ArgumentError, - "cannot parse #{inspect(naive_datetime)} to datetime, reason: #{inspect(reason)}" + "cannot convert #{inspect(naive_datetime)} to datetime, reason: #{inspect(reason)}" + end + end + + @doc """ + Changes the time zone of a `DateTime`. + + Returns a `DateTime` for the same point in time, but instead at + the time zone provided. It assumes that `DateTime` is valid and + exists in the given timezone and calendar. + + By default, it uses the default time zone database returned by + `Calendar.get_time_zone_database/0`, which defaults to + `Calendar.UTCOnlyTimeZoneDatabase` which only handles "Etc/UTC" datetimes. + Other time zone databases can be passed as argument or set globally. + See the "Time zone database" section in the module docs. + + ## Examples + + iex> cph_datetime = DateTime.from_naive!(~N[2018-07-16 12:00:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + iex> {:ok, pacific_datetime} = DateTime.shift_zone(cph_datetime, "America/Los_Angeles", FakeTimeZoneDatabase) + iex> pacific_datetime + #DateTime<2018-07-16 03:00:00-07:00 PDT America/Los_Angeles> + + """ + @doc since: "1.8.0" + @spec shift_zone(t, Calendar.time_zone(), Calendar.get_time_zone_database()) :: + {:ok, t} | {:error, :time_zone_not_found | :utc_only_time_zone_database} + def shift_zone(datetime, time_zone, time_zone_database \\ Calendar.get_time_zone_database()) + + def shift_zone(%{time_zone: time_zone} = datetime, time_zone, _) do + # When the desired time_zone is the same as the existing time_zone just return it unchanged. + {:ok, datetime} + end + + def shift_zone( + %{std_offset: std_offset, utc_offset: utc_offset} = datetime, + time_zone, + time_zone_database + ) do + iso_days_utc = + datetime + |> to_iso_days() + |> apply_tz_offset(utc_offset + std_offset) + + case time_zone_database.time_zone_period_from_utc_iso_days(iso_days_utc, time_zone) do + {:ok, %{std_offset: std_offset, utc_offset: utc_offset, zone_abbr: zone_abbr}} -> + %{calendar: calendar, microsecond: {_, microsecond_precision}} = datetime + + {year, month, day, hour, minute, second, {microsecond_without_precision, _}} = + iso_days_utc + |> apply_tz_offset(-(utc_offset + std_offset)) + |> calendar.naive_datetime_from_iso_days() + + datetime = %DateTime{ + calendar: calendar, + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: {microsecond_without_precision, microsecond_precision}, + std_offset: std_offset, + utc_offset: utc_offset, + zone_abbr: zone_abbr, + time_zone: time_zone + } + + {:ok, datetime} + + {:error, _} = error -> + error end end + @doc """ + Returns the current datetime in the provided time zone. + + By default, it uses the default time_zone returned by + `Calendar.get_time_zone_database/0`, which defaults to + `Calendar.UTCOnlyTimeZoneDatabase` which only handles "Etc/UTC" datetimes. + Other time zone databases can be passed as argument or set globally. + See the "Time zone database" section in the module docs. + + ## Examples + + iex> {:ok, datetime} = DateTime.now("Europe/Copenhagen", FakeTimeZoneDatabase) + iex> datetime.time_zone + "Europe/Copenhagen" + iex> DateTime.now("not a real time zone name", FakeTimeZoneDatabase) + {:error, :time_zone_not_found} + + """ + @doc since: "1.8.0" + @spec now(Calendar.time_zone(), Calendar.get_time_zone_database()) :: + {:ok, t} | {:error, :time_zone_not_found | :utc_only_time_zone_database} + def now(time_zone, time_zone_database \\ Calendar.get_time_zone_database()) + + def now("Etc/UTC", _) do + {:ok, utc_now()} + end + + def now(time_zone, time_zone_database) do + shift_zone(utc_now(), time_zone, time_zone_database) + end + @doc """ Converts the given `datetime` to Unix time. @@ -464,6 +704,7 @@ defmodule DateTime do The year parsed by this function is limited to four digits and, while ISO 8601 allows datetimes to specify 24:00:00 as the zero hour of the next day, this notation is not supported by Elixir. + Note leap seconds are not supported by the built-in Calendar.ISO. ## Examples @@ -489,19 +730,14 @@ defmodule DateTime do iex> DateTime.from_iso8601("2015-01-23P23:50:07") {:error, :invalid_format} - iex> DateTime.from_iso8601("2015-01-23 23:50:07A") - {:error, :invalid_format} iex> DateTime.from_iso8601("2015-01-23T23:50:07") {:error, :missing_offset} iex> DateTime.from_iso8601("2015-01-23 23:50:61") {:error, :invalid_time} iex> DateTime.from_iso8601("2015-01-32 23:50:07") {:error, :invalid_date} - iex> DateTime.from_iso8601("2015-01-23T23:50:07.123-00:00") {:error, :invalid_format} - iex> DateTime.from_iso8601("2015-01-23T23:50:07.123-00:60") - {:error, :invalid_format} """ @doc since: "1.4.0" @@ -521,14 +757,14 @@ defmodule DateTime do [match_date, guard_date, read_date] = Calendar.ISO.__match_date__() [match_time, guard_time, read_time] = Calendar.ISO.__match_time__() - defp raw_from_iso8601(string, calendar, is_negative_datetime) do + defp raw_from_iso8601(string, calendar, is_year_negative) do with <> <- string, true <- unquote(guard_date) and sep in @sep and unquote(guard_time), {microsecond, rest} <- Calendar.ISO.parse_microsecond(rest), {offset, ""} <- Calendar.ISO.parse_offset(rest) do {year, month, day} = unquote(read_date) {hour, minute, second} = unquote(read_time) - year = if is_negative_datetime, do: -year, else: year + year = if is_year_negative, do: -year, else: year cond do not calendar.valid_date?(year, month, day) -> diff --git a/lib/elixir/lib/calendar/iso.ex b/lib/elixir/lib/calendar/iso.ex index 767916d00b7..370f708844c 100644 --- a/lib/elixir/lib/calendar/iso.ex +++ b/lib/elixir/lib/calendar/iso.ex @@ -26,7 +26,7 @@ defmodule Calendar.ISO do @seconds_per_minute 60 @seconds_per_hour 60 * 60 - # Note that this does _not_ handle leap seconds. + # Note that this does *not* handle leap seconds. @seconds_per_day 24 * 60 * 60 @last_second_of_the_day @seconds_per_day - 1 @microseconds_per_second 1_000_000 @@ -507,15 +507,17 @@ defmodule Calendar.ISO do @doc """ Determines if the date given is valid according to the proleptic Gregorian calendar. - Note that leap seconds are considered valid, but the use of 24:00:00 as the - zero hour of the day is considered invalid. + + Note that while ISO 8601 allows times to specify 24:00:00 as the + zero hour of the next day, this notation is not supported by Elixir. + Leap seconds are not supported as well by the built-in Calendar.ISO. ## Examples iex> Calendar.ISO.valid_time?(10, 50, 25, {3006, 6}) true iex> Calendar.ISO.valid_time?(23, 59, 60, {0, 0}) - true + false iex> Calendar.ISO.valid_time?(24, 0, 0, {0, 0}) false @@ -525,7 +527,7 @@ defmodule Calendar.ISO do @spec valid_time?(Calendar.hour(), Calendar.minute(), Calendar.second(), Calendar.microsecond()) :: boolean def valid_time?(hour, minute, second, {microsecond, precision}) do - hour in 0..23 and minute in 0..59 and second in 0..60 and microsecond in 0..999_999 and + hour in 0..23 and minute in 0..59 and second in 0..59 and microsecond in 0..999_999 and precision in 0..6 end diff --git a/lib/elixir/lib/calendar/naive_datetime.ex b/lib/elixir/lib/calendar/naive_datetime.ex index e9bc6cb791c..9940fac6996 100644 --- a/lib/elixir/lib/calendar/naive_datetime.ex +++ b/lib/elixir/lib/calendar/naive_datetime.ex @@ -141,13 +141,11 @@ defmodule NaiveDateTime do {:ok, ~N[2000-01-01 23:59:59.0]} iex> NaiveDateTime.new(2000, 1, 1, 23, 59, 59, 999_999) {:ok, ~N[2000-01-01 23:59:59.999999]} - iex> NaiveDateTime.new(2000, 1, 1, 23, 59, 60, 999_999) - {:ok, ~N[2000-01-01 23:59:60.999999]} iex> NaiveDateTime.new(2000, 1, 1, 24, 59, 59, 999_999) {:error, :invalid_time} iex> NaiveDateTime.new(2000, 1, 1, 23, 60, 59, 999_999) {:error, :invalid_time} - iex> NaiveDateTime.new(2000, 1, 1, 23, 59, 61, 999_999) + iex> NaiveDateTime.new(2000, 1, 1, 23, 59, 60, 999_999) {:error, :invalid_time} iex> NaiveDateTime.new(2000, 1, 1, 23, 59, 59, 1_000_000) {:error, :invalid_time} @@ -514,6 +512,7 @@ defmodule NaiveDateTime do The year parsed by this function is limited to four digits and, while ISO 8601 allows datetimes to specify 24:00:00 as the zero hour of the next day, this notation is not supported by Elixir. + Note leap seconds are not supported by the built-in Calendar.ISO. ## Examples diff --git a/lib/elixir/lib/calendar/time.ex b/lib/elixir/lib/calendar/time.ex index 9ec71a6c4df..8a1cbd3f031 100644 --- a/lib/elixir/lib/calendar/time.ex +++ b/lib/elixir/lib/calendar/time.ex @@ -80,9 +80,10 @@ defmodule Time do Expects all values to be integers. Returns `{:ok, time}` if each entry fits its appropriate range, returns `{:error, reason}` otherwise. - Note a time may have 60 seconds in case of leap seconds. Microseconds - can also be given with a precision, which must be an integer between - 0 and 6. + Microseconds can also be given with a precision, which must be an + integer between 0 and 6. + + The built-in calendar does not support leap seconds. ## Examples @@ -90,18 +91,12 @@ defmodule Time do {:ok, ~T[00:00:00.000000]} iex> Time.new(23, 59, 59, 999_999) {:ok, ~T[23:59:59.999999]} - iex> Time.new(23, 59, 60, 999_999) - {:ok, ~T[23:59:60.999999]} - - # Time with microseconds and their precision - iex> Time.new(23, 59, 60, {10_000, 2}) - {:ok, ~T[23:59:60.01]} iex> Time.new(24, 59, 59, 999_999) {:error, :invalid_time} iex> Time.new(23, 60, 59, 999_999) {:error, :invalid_time} - iex> Time.new(23, 59, 61, 999_999) + iex> Time.new(23, 59, 60, 999_999) {:error, :invalid_time} iex> Time.new(23, 59, 59, 1_000_000) {:error, :invalid_time} @@ -189,6 +184,7 @@ defmodule Time do Note that while ISO 8601 allows times to specify 24:00:00 as the zero hour of the next day, this notation is not supported by Elixir. + Leap seconds are not supported as well by the built-in Calendar.ISO. ## Examples diff --git a/lib/elixir/lib/calendar/time_zone_database.ex b/lib/elixir/lib/calendar/time_zone_database.ex new file mode 100644 index 00000000000..f0d79fbd535 --- /dev/null +++ b/lib/elixir/lib/calendar/time_zone_database.ex @@ -0,0 +1,89 @@ +defmodule Calendar.TimeZoneDatabase do + @moduledoc """ + This module defines a behaviour for providing time zone data. + + IANA provides time zone data that includes data about different + UTC offsets and standard offsets for timezones. + """ + + @typedoc """ + A period where a certain combination of UTC offset, standard offset and zone + abbreviation is in effect. + + For instance one period could be the summer of 2018 in "Europe/London" where summer time / + daylight saving time is in effect and lasts from spring to autumn. At autumn the `std_offset` + changes along with the `zone_abbr` so a different period is needed during winter. + """ + @type time_zone_period :: %{ + optional(any) => any, + utc_offset: Calendar.utc_offset(), + std_offset: Calendar.std_offset(), + zone_abbr: Calendar.zone_abbr() + } + + @typedoc """ + Limit for when a certain time zone period begins or ends. + + A beginning is inclusive. An ending is exclusive. Eg. if a period is from + 2015-03-29 01:00:00 and until 2015-10-25 01:00:00, the period includes and + begins from the begining of 2015-03-29 01:00:00 and lasts until just before + 2015-10-25 01:00:00. + + A beginning or end for certain periods are infinite. For instance the latest + period for time zones without DST or plans to change. However for the purpose + of this behaviour they are only used for gaps in wall time where the needed + period limits are at a certain time. + """ + @type time_zone_period_limit :: Calendar.naive_datetime() + + @doc """ + Time zone period for a point in time in UTC for a specific time zone. + + Takes a time zone name and a point in time for UTC and returns a + `time_zone_period` for that point in time. + """ + @callback time_zone_period_from_utc_iso_days(Calendar.iso_days(), Calendar.time_zone()) :: + {:ok, time_zone_period} + | {:error, :time_zone_not_found | :utc_only_time_zone_database} + + @doc """ + Possible time zone periods for a certain time zone and wall clock date and time. + + When the provided `datetime` is ambiguous a tuple with `:ambiguous` and two possible + periods. The periods in the list are sorted with the first element being the one that begins first. + + When the provided `datetime` is in a gap - for instance during the "spring forward" when going + from winter time to summer time, a tuple with `:gap` and two periods with limits are returned + in a nested tuple. The first nested two-tuple is the period before the gap and a naive datetime + with a limit for when the period ends (wall time). The second nested two-tuple is the period + just after the gap and a datetime (wall time) for when the period begins just after the gap. + + If there is only a single possible period for the provided `datetime`, the a tuple with `:single` + and the `time_zone_period` is returned. + """ + @callback time_zone_periods_from_wall_datetime(Calendar.naive_datetime(), Calendar.time_zone()) :: + {:ok, time_zone_period} + | {:ambiguous, time_zone_period, time_zone_period} + | {:gap, {time_zone_period, time_zone_period_limit}, + {time_zone_period, time_zone_period_limit}} + | {:error, :time_zone_not_found | :utc_only_time_zone_database} +end + +defmodule Calendar.UTCOnlyTimeZoneDatabase do + @moduledoc """ + Built-in time zone database that works only in Etc/UTC. + + For all other time zones, it returns `{:error, :utc_only_time_zone_database}`. + """ + def time_zone_period_from_utc_iso_days(_, "Etc/UTC"), + do: {:ok, %{std_offset: 0, utc_offset: 0, zone_abbr: "UTC"}} + + def time_zone_period_from_utc_iso_days(_, _), + do: {:error, :utc_only_time_zone_database} + + def time_zone_periods_from_wall_datetime(_, "Etc/UTC"), + do: {:ok, %{std_offset: 0, utc_offset: 0, zone_abbr: "UTC"}} + + def time_zone_periods_from_wall_datetime(_, _), + do: {:error, :utc_only_time_zone_database} +end diff --git a/lib/elixir/src/elixir.app.src b/lib/elixir/src/elixir.app.src index d45cc5ceeff..6b7ca7349af 100644 --- a/lib/elixir/src/elixir.app.src +++ b/lib/elixir/src/elixir.app.src @@ -5,5 +5,5 @@ {registered, [elixir_config, elixir_code_server]}, {applications, [kernel,stdlib,compiler]}, {mod, {elixir,[]}}, - {env, [{ansi_enabled, false}]} + {env, [{ansi_enabled, false}, {time_zone_database, 'Elixir.Calendar.UTCOnlyTimeZoneDatabase'}]} ]}. diff --git a/lib/elixir/test/elixir/calendar_test.exs b/lib/elixir/test/elixir/calendar_test.exs index 9d4cb37b26d..f8bdc8f6311 100644 --- a/lib/elixir/test/elixir/calendar_test.exs +++ b/lib/elixir/test/elixir/calendar_test.exs @@ -1,5 +1,6 @@ Code.require_file("test_helper.exs", __DIR__) Code.require_file("fixtures/calendar/holocene.exs", __DIR__) +Code.require_file("fixtures/calendar/fake_time_zone_database.exs", __DIR__) defmodule FakeCalendar do def date_to_string(_, _, _), do: "boom" @@ -509,6 +510,14 @@ defmodule DateTimeTest do } end + test "from_iso8601 handles invalid date, time, formats correctly" do + assert DateTime.from_iso8601("2015-01-23T23:50:07") == {:error, :missing_offset} + assert DateTime.from_iso8601("2015-01-23 23:50:61") == {:error, :invalid_time} + assert DateTime.from_iso8601("2015-01-32 23:50:07") == {:error, :invalid_date} + assert DateTime.from_iso8601("2015-01-23 23:50:07A") == {:error, :invalid_format} + assert DateTime.from_iso8601("2015-01-23T23:50:07.123-00:60") == {:error, :invalid_format} + end + test "from_unix/2" do min_datetime = %DateTime{ calendar: Calendar.ISO, @@ -878,4 +887,179 @@ defmodule DateTimeTest do assert DateTime.diff(dt1, dt2) == 3_281_904_000 end + + describe "from_naive" do + test "uses default time zone database from config" do + Calendar.put_time_zone_database(FakeTimeZoneDatabase) + + assert DateTime.from_naive( + ~N[2018-07-01 12:34:25.123456], + "Europe/Copenhagen", + FakeTimeZoneDatabase + ) == + {:ok, + %DateTime{ + day: 1, + hour: 12, + microsecond: {123_456, 6}, + minute: 34, + month: 7, + second: 25, + std_offset: 3600, + time_zone: "Europe/Copenhagen", + utc_offset: 3600, + year: 2018, + zone_abbr: "CEST" + }} + after + Calendar.put_time_zone_database(Calendar.UTCOnlyTimeZoneDatabase) + end + + test "with compatible calendar on unambiguous wall clock" do + holocene_ndt = %NaiveDateTime{ + calendar: Calendar.Holocene, + year: 12018, + month: 7, + day: 1, + hour: 12, + minute: 34, + second: 25, + microsecond: {123_456, 6} + } + + assert DateTime.from_naive(holocene_ndt, "Europe/Copenhagen", FakeTimeZoneDatabase) == + {:ok, + %DateTime{ + calendar: Calendar.Holocene, + day: 1, + hour: 12, + microsecond: {123_456, 6}, + minute: 34, + month: 7, + second: 25, + std_offset: 3600, + time_zone: "Europe/Copenhagen", + utc_offset: 3600, + year: 12018, + zone_abbr: "CEST" + }} + end + + test "with compatible calendar on ambiguous wall clock" do + holocene_ndt = %NaiveDateTime{ + calendar: Calendar.Holocene, + year: 12018, + month: 10, + day: 28, + hour: 02, + minute: 30, + second: 00, + microsecond: {123_456, 6} + } + + assert {:ambiguous, first_dt, second_dt} = + DateTime.from_naive(holocene_ndt, "Europe/Copenhagen", FakeTimeZoneDatabase) + + assert %DateTime{calendar: Calendar.Holocene, zone_abbr: "CEST"} = first_dt + assert %DateTime{calendar: Calendar.Holocene, zone_abbr: "CET"} = second_dt + end + + test "with compatible calendar on gap" do + holocene_ndt = %NaiveDateTime{ + calendar: Calendar.Holocene, + year: 12019, + month: 03, + day: 31, + hour: 02, + minute: 30, + second: 00, + microsecond: {123_456, 6} + } + + assert {:gap, first_dt, second_dt} = + DateTime.from_naive(holocene_ndt, "Europe/Copenhagen", FakeTimeZoneDatabase) + + assert %DateTime{calendar: Calendar.Holocene, zone_abbr: "CET"} = first_dt + assert %DateTime{calendar: Calendar.Holocene, zone_abbr: "CEST"} = second_dt + end + + test "with incompatible calendar" do + ndt = %{~N[2018-07-20 00:00:00] | calendar: FakeCalendar} + + assert DateTime.from_naive(ndt, "Europe/Copenhagen", FakeTimeZoneDatabase) == + {:error, :incompatible_calendars} + end + end + + describe "from_naive!" do + test "raises on ambiguous wall clock" do + assert_raise ArgumentError, ~r"ambiguous", fn -> + DateTime.from_naive!(~N[2018-10-28 02:30:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + end + end + + test "raises on gap" do + assert_raise ArgumentError, ~r"gap", fn -> + DateTime.from_naive!(~N[2019-03-31 02:30:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + end + end + end + + describe "shift_zone" do + test "with compatible calendar" do + holocene_ndt = %NaiveDateTime{ + calendar: Calendar.Holocene, + year: 12018, + month: 7, + day: 1, + hour: 12, + minute: 34, + second: 25, + microsecond: {123_456, 6} + } + + {:ok, holocene_dt} = + DateTime.from_naive(holocene_ndt, "Europe/Copenhagen", FakeTimeZoneDatabase) + + {:ok, dt} = DateTime.shift_zone(holocene_dt, "America/Los_Angeles", FakeTimeZoneDatabase) + + assert dt == %DateTime{ + calendar: Calendar.Holocene, + day: 1, + hour: 3, + microsecond: {123_456, 6}, + minute: 34, + month: 7, + second: 25, + std_offset: 3600, + time_zone: "America/Los_Angeles", + utc_offset: -28800, + year: 12018, + zone_abbr: "PDT" + } + end + + test "uses default time zone database from config" do + Calendar.put_time_zone_database(FakeTimeZoneDatabase) + + {:ok, dt} = DateTime.from_naive(~N[2018-07-01 12:34:25.123456], "Europe/Copenhagen") + {:ok, dt} = DateTime.shift_zone(dt, "America/Los_Angeles") + + assert dt == %DateTime{ + day: 1, + hour: 3, + microsecond: {123_456, 6}, + minute: 34, + month: 7, + second: 25, + std_offset: 3600, + time_zone: "America/Los_Angeles", + utc_offset: -28800, + year: 2018, + zone_abbr: "PDT" + } + after + Calendar.put_time_zone_database(Calendar.UTCOnlyTimeZoneDatabase) + end + end end diff --git a/lib/elixir/test/elixir/fixtures/calendar/fake_time_zone_database.exs b/lib/elixir/test/elixir/fixtures/calendar/fake_time_zone_database.exs new file mode 100644 index 00000000000..f657b5e266a --- /dev/null +++ b/lib/elixir/test/elixir/fixtures/calendar/fake_time_zone_database.exs @@ -0,0 +1,150 @@ +defmodule FakeTimeZoneDatabase do + @behaviour Calendar.TimeZoneDatabase + + @time_zone_period_cph_summer_2018 %{ + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST", + from_wall: ~N[2018-03-25 03:00:00], + until_wall: ~N[2018-10-28 03:00:00] + } + + @time_zone_period_cph_winter_2018_2019 %{ + std_offset: 0, + utc_offset: 3600, + zone_abbr: "CET", + from_wall: ~N[2018-10-28 02:00:00], + until_wall: ~N[2019-03-31 02:00:00] + } + + @time_zone_period_cph_summer_2019 %{ + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST", + from_wall: ~N[2019-03-31 03:00:00], + until_wall: ~N[2019-10-27 03:00:00] + } + + @spec time_zone_period_from_utc_iso_days(Calendar.iso_days(), Calendar.time_zone()) :: + {:ok, TimeZoneDatabase.time_zone_period()} | {:error, :time_zone_not_found} + @impl true + def time_zone_period_from_utc_iso_days(iso_days, time_zone) do + {:ok, ndt} = naive_datetime_from_iso_days(iso_days) + time_zone_periods_from_utc(time_zone, NaiveDateTime.to_erl(ndt)) + end + + @spec time_zone_periods_from_wall_datetime(Calendar.naive_datetime(), Calendar.time_zone()) :: + {:ok, TimeZoneDatabase.time_zone_period()} + | {:ambiguous, TimeZoneDatabase.time_zone_period(), TimeZoneDatabase.time_zone_period()} + | {:gap, + {TimeZoneDatabase.time_zone_period(), TimeZoneDatabase.time_zone_period_limit()}, + {TimeZoneDatabase.time_zone_period(), TimeZoneDatabase.time_zone_period_limit()}} + | {:error, :time_zone_not_found} + @impl true + def time_zone_periods_from_wall_datetime(naive_datetime, time_zone) do + time_zone_periods_from_wall(time_zone, NaiveDateTime.to_erl(naive_datetime)) + end + + defp time_zone_periods_from_utc("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2018, 3, 25}, {1, 0, 0}} and + erl_datetime < {{2018, 10, 28}, {3, 0, 0}} do + {:ok, @time_zone_period_cph_summer_2018} + end + + defp time_zone_periods_from_utc("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2018, 10, 28}, {2, 0, 0}} and + erl_datetime < {{2019, 3, 31}, {2, 0, 0}} do + {:ok, @time_zone_period_cph_winter_2018_2019} + end + + defp time_zone_periods_from_utc("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2015, 3, 29}, {1, 0, 0}} and + erl_datetime < {{2015, 10, 25}, {1, 0, 0}} do + {:ok, + %{ + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST" + }} + end + + defp time_zone_periods_from_utc("America/Los_Angeles", erl_datetime) + when erl_datetime >= {{2018, 3, 11}, {10, 0, 0}} and + erl_datetime < {{2018, 11, 4}, {9, 0, 0}} do + {:ok, + %{ + std_offset: 3600, + utc_offset: -28800, + zone_abbr: "PDT" + }} + end + + defp time_zone_periods_from_utc(time_zone, _) when time_zone != "Europe/Copenhagen" do + {:error, :time_zone_not_found} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2019, 3, 31}, {2, 0, 0}} and + erl_datetime < {{2019, 3, 31}, {3, 0, 0}} do + {:gap, + {@time_zone_period_cph_winter_2018_2019, @time_zone_period_cph_winter_2018_2019.until_wall}, + {@time_zone_period_cph_summer_2019, @time_zone_period_cph_summer_2019.from_wall}} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime < {{2018, 10, 28}, {3, 0, 0}} and + erl_datetime >= {{2018, 10, 28}, {2, 0, 0}} do + {:ambiguous, @time_zone_period_cph_summer_2018, @time_zone_period_cph_winter_2018_2019} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2018, 3, 25}, {3, 0, 0}} and + erl_datetime < {{2018, 10, 28}, {3, 0, 0}} do + {:ok, @time_zone_period_cph_summer_2018} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2018, 10, 28}, {2, 0, 0}} and + erl_datetime < {{2019, 3, 31}, {2, 0, 0}} do + {:ok, @time_zone_period_cph_winter_2018_2019} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2019, 3, 31}, {3, 0, 0}} and + erl_datetime < {{2019, 10, 27}, {3, 0, 0}} do + {:ok, @time_zone_period_cph_summer_2019} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2015, 3, 29}, {3, 0, 0}} and + erl_datetime < {{2015, 10, 25}, {3, 0, 0}} do + {:ok, + %{ + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST" + }} + end + + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) + when erl_datetime >= {{2090, 3, 26}, {3, 0, 0}} and + erl_datetime < {{2090, 10, 29}, {3, 0, 0}} do + {:ok, + %{ + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST" + }} + end + + defp time_zone_periods_from_wall(time_zone, _) when time_zone != "Europe/Copenhagen" do + {:error, :time_zone_not_found} + end + + defp naive_datetime_from_iso_days(iso_days) do + {year, month, day, hour, minute, second, microsecond} = + Calendar.ISO.naive_datetime_from_iso_days(iso_days) + + NaiveDateTime.new(year, month, day, hour, minute, second, microsecond) + end +end