diff --git a/lib/postgrex/extensions/timestamptz.ex b/lib/postgrex/extensions/timestamptz.ex index acd2d89c..888e5bdb 100644 --- a/lib/postgrex/extensions/timestamptz.ex +++ b/lib/postgrex/extensions/timestamptz.ex @@ -37,8 +37,19 @@ defmodule Postgrex.Extensions.TimestampTZ do ## Helpers def encode_elixir(%DateTime{utc_offset: 0, std_offset: 0} = datetime) do - microsecs = DateTime.to_unix(datetime, :microsecond) - <<8::int32(), microsecs - @us_epoch::int64()>> + microsecs = DateTime.to_unix(datetime, :microsecond) - @us_epoch + + # PostgreSQL rejects timestamps outside its own range, but only when it + # receives the value we meant to send. Anything that does not fit in a + # signed 64-bit integer is silently truncated by the binary construction + # below and arrives as a different, in-range timestamp; the two endpoints + # are the infinity sentinels. + if microsecs > @minus_infinity and microsecs < @plus_infinity do + <<8::int32(), microsecs::int64()>> + else + raise ArgumentError, + "#{inspect(datetime)} is beyond the range PostgreSQL can represent in a timestamptz" + end end def encode_elixir(%DateTime{} = datetime) do diff --git a/test/calendar_test.exs b/test/calendar_test.exs index 36c0df6a..16db094c 100644 --- a/test/calendar_test.exs +++ b/test/calendar_test.exs @@ -299,6 +299,27 @@ defmodule CalendarTest do assert message =~ "timestamp out of range" end + if Version.match?(System.version(), ">= 1.19.0") do + test "encode timestamptz beyond the 64-bit wire range", context do + # These do not fit in the signed 64-bit microsecond wire format. Truncating + # them sends PostgreSQL a different, in-range timestamp that it accepts, so + # the value has to be rejected here instead. + sentinel = + DateTime.from_naive!(NaiveDateTime.new!(294_277, 1, 9, 4, 0, 54, {775_807, 6}), "Etc/UTC") + + wraps_into_range = + DateTime.from_naive!(NaiveDateTime.new!(585_706, 1, 1, 0, 0, 0), "Etc/UTC") + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [sentinel]) + end + + assert_raise ArgumentError, ~r/beyond the range/, fn -> + query("SELECT $1::timestamptz", [wraps_into_range]) + end + end + end + @tag :capture_log test "decode infinity", context do assert_raise ArgumentError, fn -> query("SELECT 'infinity'::timestamp", []) end