From 7ca8466a8828a62ddc48e44952b69b6206da866e Mon Sep 17 00:00:00 2001 From: KBS Date: Sun, 27 Sep 2026 23:56:46 +0900 Subject: [PATCH] Normalize offset strings to UTC when casting to naive types #4775 normalized a non-UTC %DateTime{} to UTC for :date, :naive_datetime and :naive_datetime_usec, but an ISO 8601 string with the same offset still went through NaiveDateTime.from_iso8601, which drops the offset. The same instant cast two ways gave two values: cast(:naive_datetime, "2020-06-01T00:30:07+02:00") ~N[2020-06-01 00:30:07] cast(:naive_datetime, ) ~N[2020-05-31 22:30:07] Parse with DateTime.from_iso8601 first and fall back to NaiveDateTime.from_iso8601, so strings without an offset are unchanged. --- CHANGELOG.md | 1 + lib/ecto/type.ex | 26 ++++++++++++++++++-------- test/ecto/type_test.exs | 8 ++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b0e76bff..395b67a0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * [Ecto.Query] Fix subqueries in interpolated join filters (this changes the representation of `JoinExpr.on` fields to a `BooleanExpr`) * [Ecto.Query] Fix selecting lists of fields and using `map/2` from subqueries * [Ecto.Type] Normalize `DateTime` casts to UTC for `:time` and `:time_usec` + * [Ecto.Type] Normalize ISO 8601 strings with a UTC offset to UTC when casting to `:date`, `:naive_datetime` and `:naive_datetime_usec` ## v3.14.2 (2026-08-14) diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex index 107cafce71..adadc92eb3 100644 --- a/lib/ecto/type.ex +++ b/lib/ecto/type.ex @@ -1062,9 +1062,9 @@ defmodule Ecto.Type do ok {:error, _} -> - case NaiveDateTime.from_iso8601(binary) do + case parse_naive_datetime(binary) do {:ok, naive_datetime} -> {:ok, NaiveDateTime.to_date(naive_datetime)} - {:error, _} -> :error + :error -> :error end end end @@ -1199,12 +1199,8 @@ defmodule Ecto.Type do end end - defp cast_naive_datetime(binary) when is_binary(binary) do - case NaiveDateTime.from_iso8601(binary) do - {:ok, _} = ok -> ok - {:error, _} -> :error - end - end + defp cast_naive_datetime(binary) when is_binary(binary), + do: parse_naive_datetime(binary) defp cast_naive_datetime(%DateTime{} = datetime) do case cast_utc_datetime(datetime) do @@ -1240,6 +1236,20 @@ defmodule Ecto.Type do :error end + # A string with a UTC offset is normalized to UTC, like a %DateTime{}. + defp parse_naive_datetime(binary) do + case DateTime.from_iso8601(binary) do + {:ok, datetime, _offset} -> + {:ok, DateTime.to_naive(datetime)} + + {:error, _} -> + case NaiveDateTime.from_iso8601(binary) do + {:ok, _} = ok -> ok + {:error, _} -> :error + end + end + end + ## UTC datetime defp cast_utc_datetime("-" <> rest) do diff --git a/test/ecto/type_test.exs b/test/ecto/type_test.exs index b84aa03961..74b50dff84 100644 --- a/test/ecto/type_test.exs +++ b/test/ecto/type_test.exs @@ -450,6 +450,7 @@ defmodule Ecto.TypeTest do assert Ecto.Type.cast(:date, "2015-12-31T00:00:00") == {:ok, @date} assert Ecto.Type.cast(:date, "2015-12-31 00:00:00") == {:ok, @date} + assert Ecto.Type.cast(:date, "2016-01-01T01:00:00+02:00") == {:ok, @date} end test "dump" do @@ -653,6 +654,10 @@ defmodule Ecto.TypeTest do assert Ecto.Type.cast(:naive_datetime, "2015-01-23T23:50:07.008000") == {:ok, @datetime} assert Ecto.Type.cast(:naive_datetime, "2015-01-23T23:50:07.008000Z") == {:ok, @datetime} + assert Ecto.Type.cast(:naive_datetime, "2015-01-24T09:50:07+10:00") == {:ok, @datetime} + + assert Ecto.Type.cast(:naive_datetime, "2015-01-23T17:50:07.008000-06:00") == + {:ok, @datetime} assert Ecto.Type.cast(:naive_datetime, "2015-01-23P23:50:07") == :error assert Ecto.Type.cast(:naive_datetime, "2015-01-23 23:50.123") == :error @@ -869,6 +874,9 @@ defmodule Ecto.TypeTest do assert Ecto.Type.cast(:naive_datetime_usec, "2015-01-23T23:50:07.008000Z") == {:ok, @datetime_usec} + assert Ecto.Type.cast(:naive_datetime_usec, "2015-01-23T17:50:07.008000-06:00") == + {:ok, @datetime_usec} + assert Ecto.Type.cast(:naive_datetime_usec, "2015-01-23P23:50:07") == :error end