From 66201e32a4239a684dba176e6af3dfb700789eab Mon Sep 17 00:00:00 2001 From: ybai08 <271969568+ybai08@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:47:14 -0500 Subject: [PATCH] Handle negative years only for datetimes without seconds Negative years were handled by stripping the leading minus, casting the rest and negating the resulting year. For :utc_datetime this applied the offset to the positive year first, so a negative year datetime whose offset crosses a year boundary was off by years ("-2015-12-31T23:50:07-01:00" cast to -2016-01-01 instead of -2014-01-01). It also made strings with two leading minus signs cast to positive years. NaiveDateTime.from_iso8601/1 and DateTime.from_iso8601/1 already parse negative years, so only apply the sign handling to the format without seconds, which those functions do not support. --- lib/ecto/type.ex | 46 +++++++++++++++++++---------------------- test/ecto/type_test.exs | 15 ++++++++++++++ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex index a5b0627fa3..da68d9bb9c 100644 --- a/lib/ecto/type.ex +++ b/lib/ecto/type.ex @@ -1168,27 +1168,10 @@ defmodule Ecto.Type do ## Naive datetime - defp cast_naive_datetime("-" <> rest) do - with {:ok, naive_datetime} <- cast_naive_datetime(rest) do - {:ok, %{naive_datetime | year: naive_datetime.year * -1}} - end - end - - defp cast_naive_datetime( - <> - ) - when sep in [?\s, ?T] do - with {:ok, date} <- cast_date(to_i(year), to_i(month), to_i(day)), - {:ok, time} <- cast_time(to_i(hour), to_i(minute), 0, nil) do - NaiveDateTime.new(date, time) - end - end - defp cast_naive_datetime(binary) when is_binary(binary) do case NaiveDateTime.from_iso8601(binary) do {:ok, _} = ok -> ok - {:error, _} -> :error + {:error, _} -> cast_naive_datetime_without_seconds(binary) end end @@ -1219,25 +1202,35 @@ defmodule Ecto.Type do :error end - ## UTC datetime + # The ISO 8601 parsers already handle negative years, so the sign + # only needs special handling for datetimes without seconds. + defp cast_naive_datetime_without_seconds("-" <> rest) do + case cast_naive_datetime_without_seconds(rest) do + {:ok, %{year: year} = naive_datetime} when year >= 0 -> + {:ok, %{naive_datetime | year: -year}} - defp cast_utc_datetime("-" <> rest) do - with {:ok, utc_datetime} <- cast_utc_datetime(rest) do - {:ok, %{utc_datetime | year: utc_datetime.year * -1}} + _ -> + :error end end - defp cast_utc_datetime( + defp cast_naive_datetime_without_seconds( <> ) when sep in [?\s, ?T] do with {:ok, date} <- cast_date(to_i(year), to_i(month), to_i(day)), {:ok, time} <- cast_time(to_i(hour), to_i(minute), 0, nil) do - DateTime.new(date, time, "Etc/UTC") + NaiveDateTime.new(date, time) end end + defp cast_naive_datetime_without_seconds(_) do + :error + end + + ## UTC datetime + defp cast_utc_datetime(binary) when is_binary(binary) do case DateTime.from_iso8601(binary) do {:ok, datetime, _offset} -> @@ -1250,7 +1243,10 @@ defmodule Ecto.Type do end {:error, _} -> - :error + case cast_naive_datetime_without_seconds(binary) do + {:ok, naive_datetime} -> {:ok, DateTime.from_naive!(naive_datetime, "Etc/UTC")} + :error -> :error + end end end diff --git a/test/ecto/type_test.exs b/test/ecto/type_test.exs index 06743295ad..b397db0c82 100644 --- a/test/ecto/type_test.exs +++ b/test/ecto/type_test.exs @@ -812,6 +812,8 @@ defmodule Ecto.TypeTest do assert Ecto.Type.cast(:naive_datetime, "-2015-01-23 23:50") == {:ok, datetime_zero} assert Ecto.Type.cast(:naive_datetime, "-2015-01-23 23:50:07") == {:ok, datetime} assert Ecto.Type.cast(:naive_datetime, "-2015-01-23 23:50:07bad") == :error + assert Ecto.Type.cast(:naive_datetime, "--2015-01-23 23:50") == :error + assert Ecto.Type.cast(:naive_datetime, "--2015-01-23 23:50:07") == :error end test "dump" do @@ -1132,6 +1134,19 @@ defmodule Ecto.TypeTest do {:ok, ~U[-2015-01-23 23:50:07Z]} assert Ecto.Type.cast(:utc_datetime, "-2015-01-23 23:50:07bad") == :error + assert Ecto.Type.cast(:utc_datetime, "--2015-01-23 23:50") == :error + assert Ecto.Type.cast(:utc_datetime, "--2015-01-23 23:50:07") == :error + end + + test "cast negative datetime with offset" do + assert Ecto.Type.cast(:utc_datetime, "-2015-01-23T23:50:07+01:00") == + {:ok, ~U[-2015-01-23 22:50:07Z]} + + assert Ecto.Type.cast(:utc_datetime, "-2015-12-31T23:50:07-01:00") == + {:ok, ~U[-2014-01-01 00:50:07Z]} + + assert Ecto.Type.cast(:utc_datetime, "-0001-01-01T00:50:07+01:00") == + {:ok, ~U[-0002-12-31 23:50:07Z]} end test "dump" do