From 330d9a2691d0a19ce4b5a97e3344c99cb5c8e042 Mon Sep 17 00:00:00 2001 From: Tristan Sloughter Date: Tue, 12 Nov 2024 06:13:52 -0700 Subject: [PATCH 1/5] Add UTC offset to timestampz encoder --- src/pg_timestamp.erl | 22 ++++++++++++++++++++++ src/pg_timestampz.erl | 2 +- test/prop_timestamp.erl | 16 ++++++++++++++++ test/proper_lib.erl | 6 +++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/pg_timestamp.erl b/src/pg_timestamp.erl index 80605ba..fa84d1a 100644 --- a/src/pg_timestamp.erl +++ b/src/pg_timestamp.erl @@ -49,9 +49,24 @@ encode_timestamp(infinity) -> 16#7FFFFFFFFFFFFFFF; encode_timestamp('-infinity') -> -16#8000000000000000; +encode_timestamp({{Year, Month, Day}, {Hour, Minute, Seconds}, {HourOffset, MinuteOffset}}) when is_integer(Seconds) -> + Sign = determine_sign(HourOffset), + OffsetFromHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}), + OffsetFromMinutes = calendar:time_to_seconds({0, MinuteOffset, 0}), + DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, {Hour, Minute, Seconds}}) - ?POSTGRESQL_GS_EPOCH, + (DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes) * 1000000; encode_timestamp(Datetime={{_, _, _}, {_, _, Seconds}}) when is_integer(Seconds)-> Secs = calendar:datetime_to_gregorian_seconds(Datetime) - ?POSTGRESQL_GS_EPOCH, Secs * 1000000; +encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}, {HourOffset, MinuteOffset}}) when is_float(Seconds) -> + Sign = determine_sign(HourOffset), + OffsetFromHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}), + OffsetFromMinutes = calendar:time_to_seconds({0, MinuteOffset, 0}), + IntegerSeconds = trunc(Seconds), + US = trunc((Seconds - IntegerSeconds) * 1000000), + DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, + {Hours, Minutes, IntegerSeconds}}) - ?POSTGRESQL_GS_EPOCH, + ((DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes) * 1000000) + US; encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}}) when is_float(Seconds)-> IntegerSeconds = trunc(Seconds), US = trunc((Seconds - IntegerSeconds) * 1000000), @@ -88,3 +103,10 @@ add_usecs(Secs, 0) -> Secs; add_usecs(Secs, USecs) -> Secs + (USecs / 1000000). + +% When the hour offset is positive, you are in the future and therefore +% need to subtract the hours to get to UTC. +determine_sign(HourOffset) when HourOffset >= 0 -> + -1; +determine_sign(_) -> + 1. diff --git a/src/pg_timestampz.erl b/src/pg_timestampz.erl index 915802e..cb424ee 100644 --- a/src/pg_timestampz.erl +++ b/src/pg_timestampz.erl @@ -19,4 +19,4 @@ decode(Bin, _TypeInfo) -> pg_timestamp:decode_timestamp(Bin, []). type_spec() -> - pg_timestamp:type_spec(). + "{{Year::integer(), Month::1..12, Day::1..31}, {Hours::0..23, Minutes::0..59, Seconds::0..59 | float()}, {HourOffset::-15..15, MinuteOffset::0..59}}". diff --git a/test/prop_timestamp.erl b/test/prop_timestamp.erl index 55e9b2e..1764ae0 100644 --- a/test/prop_timestamp.erl +++ b/test/prop_timestamp.erl @@ -9,5 +9,21 @@ prop_tz_int_sec_codec() -> ?FORALL(Val, int_timestamp(), proper_lib:codec(pg_timestampz, [], Val)). +prop_tz_offset_codec() -> + ?FORALL(Val, utc_offset_timestamp(), + proper_lib:codec(pg_timestampz, [], without_offset(Val))). + +without_offset({{Year, Month, Day}, {Hour, Minute, Second}, {HourOffset, MinuteOffset}}) -> + Sign = case HourOffset >= 0 of + true -> 1; + false -> -1 + end, + OffsetSeconds = calendar:time_to_seconds({abs(HourOffset), MinuteOffset, 0}), + DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, {Hour, Minute, Second}}), + calendar:gregorian_seconds_to_datetime(DatetimeSeconds + OffsetSeconds * Sign). + int_timestamp() -> {proper_lib:date_gen(), proper_lib:int_time_gen()}. + +utc_offset_timestamp() -> + {proper_lib:date_gen(), proper_lib:int_time_gen(), proper_lib:utc_offset_gen()}. diff --git a/test/proper_lib.erl b/test/proper_lib.erl index 366ddae..ec658e6 100644 --- a/test/proper_lib.erl +++ b/test/proper_lib.erl @@ -3,7 +3,7 @@ -export([codec/3, codec/4]). -export([int16/0, int32/0, int64/0, - date_gen/0, int_time_gen/0]). + date_gen/0, int_time_gen/0, utc_offset_gen/0]). -include_lib("proper/include/proper.hrl"). -include_lib("stdlib/include/assert.hrl"). @@ -39,6 +39,10 @@ date_gen() -> proper_types:integer(1, 31)}, calendar:valid_date(Date)). +utc_offset_gen() -> + {proper_types:integer(-15, 15), + proper_types:integer(0, 59)}. + int_time_gen() -> {proper_types:integer(0, 23), proper_types:integer(0, 59), From ee0a639f4bd7c8a3adeed33015c33acf4f4c548b Mon Sep 17 00:00:00 2001 From: chiroptical Date: Mon, 31 Mar 2025 08:48:27 -0400 Subject: [PATCH 2/5] Update type_spec to reflect possible inputs --- src/pg_timestampz.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pg_timestampz.erl b/src/pg_timestampz.erl index cb424ee..f9fc9be 100644 --- a/src/pg_timestampz.erl +++ b/src/pg_timestampz.erl @@ -19,4 +19,4 @@ decode(Bin, _TypeInfo) -> pg_timestamp:decode_timestamp(Bin, []). type_spec() -> - "{{Year::integer(), Month::1..12, Day::1..31}, {Hours::0..23, Minutes::0..59, Seconds::0..59 | float()}, {HourOffset::-15..15, MinuteOffset::0..59}}". + "{{Year::integer(), Month::1..12, Day::1..31}, {Hours::integer(), Minutes::integer(), Seconds::integer() | float()}, {HourOffset::integer(), MinuteOffset::integer()}}". From 548904a44c908237bd99cb88af54b783107c836d Mon Sep 17 00:00:00 2001 From: chiroptical Date: Mon, 31 Mar 2025 08:53:13 -0400 Subject: [PATCH 3/5] Minute offset must be positive --- src/pg_timestampz.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pg_timestampz.erl b/src/pg_timestampz.erl index f9fc9be..959b27a 100644 --- a/src/pg_timestampz.erl +++ b/src/pg_timestampz.erl @@ -19,4 +19,4 @@ decode(Bin, _TypeInfo) -> pg_timestamp:decode_timestamp(Bin, []). type_spec() -> - "{{Year::integer(), Month::1..12, Day::1..31}, {Hours::integer(), Minutes::integer(), Seconds::integer() | float()}, {HourOffset::integer(), MinuteOffset::integer()}}". + "{{Year::integer(), Month::1..12, Day::1..31}, {Hours::integer(), Minutes::integer(), Seconds::integer() | float()}, {HourOffset::integer(), MinuteOffset::pos_integer()}}". From 2f1f6179226a82b062783b4c78bb3e0a49146663 Mon Sep 17 00:00:00 2001 From: chiroptical Date: Wed, 2 Apr 2025 12:11:02 -0400 Subject: [PATCH 4/5] Add unit tests and fix offset bug --- .github/workflows/ci.yml | 3 +++ src/pg_timestamp.erl | 8 ++++---- test/prop_timestamp.erl | 13 +++++++------ test/proper_lib.erl | 20 ++++++++++++++++---- test/timestamptz_tests.erl | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 test/timestamptz_tests.erl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e6f064..a662504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Compile run: rebar3 compile + - name: Eunit tests + run: rebar3 eunit + - name: Proper tests run: rebar3 as test proper diff --git a/src/pg_timestamp.erl b/src/pg_timestamp.erl index fa84d1a..1b8453e 100644 --- a/src/pg_timestamp.erl +++ b/src/pg_timestamp.erl @@ -49,16 +49,16 @@ encode_timestamp(infinity) -> 16#7FFFFFFFFFFFFFFF; encode_timestamp('-infinity') -> -16#8000000000000000; -encode_timestamp({{Year, Month, Day}, {Hour, Minute, Seconds}, {HourOffset, MinuteOffset}}) when is_integer(Seconds) -> +encode_timestamp({{Year, Month, Day}, {Hour, Minute, Seconds}, {HourOffset, MinuteOffset}}) when is_integer(Seconds), MinuteOffset >= 0 -> Sign = determine_sign(HourOffset), OffsetFromHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}), OffsetFromMinutes = calendar:time_to_seconds({0, MinuteOffset, 0}), DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, {Hour, Minute, Seconds}}) - ?POSTGRESQL_GS_EPOCH, - (DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes) * 1000000; + (DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes * Sign) * 1000000; encode_timestamp(Datetime={{_, _, _}, {_, _, Seconds}}) when is_integer(Seconds)-> Secs = calendar:datetime_to_gregorian_seconds(Datetime) - ?POSTGRESQL_GS_EPOCH, Secs * 1000000; -encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}, {HourOffset, MinuteOffset}}) when is_float(Seconds) -> +encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}, {HourOffset, MinuteOffset}}) when is_float(Seconds), MinuteOffset >= 0 -> Sign = determine_sign(HourOffset), OffsetFromHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}), OffsetFromMinutes = calendar:time_to_seconds({0, MinuteOffset, 0}), @@ -66,7 +66,7 @@ encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}, {HourOffset, Mi US = trunc((Seconds - IntegerSeconds) * 1000000), DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, {Hours, Minutes, IntegerSeconds}}) - ?POSTGRESQL_GS_EPOCH, - ((DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes) * 1000000) + US; + ((DatetimeSeconds + OffsetFromHours * Sign + OffsetFromMinutes * Sign) * 1000000) + US; encode_timestamp({{Year, Month, Day}, {Hours, Minutes, Seconds}}) when is_float(Seconds)-> IntegerSeconds = trunc(Seconds), US = trunc((Seconds - IntegerSeconds) * 1000000), diff --git a/test/prop_timestamp.erl b/test/prop_timestamp.erl index 1764ae0..a014780 100644 --- a/test/prop_timestamp.erl +++ b/test/prop_timestamp.erl @@ -11,16 +11,17 @@ prop_tz_int_sec_codec() -> prop_tz_offset_codec() -> ?FORALL(Val, utc_offset_timestamp(), - proper_lib:codec(pg_timestampz, [], without_offset(Val))). + proper_lib:codec(pg_timestampz, [], Val, apply_offset(Val))). -without_offset({{Year, Month, Day}, {Hour, Minute, Second}, {HourOffset, MinuteOffset}}) -> +apply_offset({{Year, Month, Day}, {Hour, Minute, Second}, {HourOffset, MinuteOffset}}) -> Sign = case HourOffset >= 0 of - true -> 1; - false -> -1 + true -> -1; + false -> 1 end, - OffsetSeconds = calendar:time_to_seconds({abs(HourOffset), MinuteOffset, 0}), + OffsetHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}), + OffsetMinutes = calendar:time_to_seconds({0, MinuteOffset, 0}), DatetimeSeconds = calendar:datetime_to_gregorian_seconds({{Year, Month, Day}, {Hour, Minute, Second}}), - calendar:gregorian_seconds_to_datetime(DatetimeSeconds + OffsetSeconds * Sign). + calendar:gregorian_seconds_to_datetime(DatetimeSeconds + OffsetHours * Sign + OffsetMinutes * Sign). int_timestamp() -> {proper_lib:date_gen(), proper_lib:int_time_gen()}. diff --git a/test/proper_lib.erl b/test/proper_lib.erl index ec658e6..77f6e14 100644 --- a/test/proper_lib.erl +++ b/test/proper_lib.erl @@ -1,6 +1,6 @@ -module(proper_lib). --export([codec/3, codec/4]). +-export([codec/3, codec/4, encode_decode/3]). -export([int16/0, int32/0, int64/0, date_gen/0, int_time_gen/0, utc_offset_gen/0]). @@ -12,11 +12,23 @@ codec(Mod, Opts, Data) -> codec(Mod, Opts, Data, fun(V) -> V end). -codec(Mod, Opts, Data, Canonical) -> +% Encode and then decode the Input +encode_decode(Mod, Opts, Input) -> {_, Config} = Mod:init(Opts), TypeInfo = #type_info{config = Config}, - <> = iolist_to_binary(Mod:encode(Data, TypeInfo)), - Canonical(Data) =:= Canonical(Mod:decode(Encoded, TypeInfo)). + <> = iolist_to_binary(Mod:encode(Input, TypeInfo)), + Mod:decode(Encoded, TypeInfo). + +% Passing a function will apply that function to the input Data and +% encoded/decoded Data before comparison. +codec(Mod, Opts, Data, Canonical) when is_function(Canonical) -> + Decoded = encode_decode(Mod, Opts, Data), + Canonical(Data) =:= Canonical(Decoded); +% Passing anything else will simply compare the Output with the encoded/decoded +% Input +codec(Mod, Opts, Data, Output) -> + Decoded = encode_decode(Mod, Opts, Data), + Output =:= Decoded. %% %% Generators diff --git a/test/timestamptz_tests.erl b/test/timestamptz_tests.erl new file mode 100644 index 0000000..d96ffd6 --- /dev/null +++ b/test/timestamptz_tests.erl @@ -0,0 +1,33 @@ +-module(timestamptz_tests). + +-include_lib("eunit/include/eunit.hrl"). + +negative_offset_timestamptz_test() -> + ?assertEqual( + proper_lib:encode_decode( + pg_timestampz, + [], + {{2024, 3, 1}, {1, 0, 0}, {-5, 15}} + ), + {{2024, 3, 1}, {6, 15, 0}} + ). + +positive_offset_timestamptz_test() -> + ?assertEqual( + proper_lib:encode_decode( + pg_timestampz, + [], + {{2024, 3, 1}, {1, 0, 0}, {5, 15}} + ), + {{2024, 2, 29}, {19, 45, 0}} + ). + +no_offset_timestamptz_test() -> + ?assertEqual( + proper_lib:encode_decode( + pg_timestampz, + [], + {{2024, 3, 1}, {1, 0, 0}, {0, 0}} + ), + {{2024, 3, 1}, {1, 0, 0}} + ). From 6445cd96e06b69b08ca220f721dc06690ae86108 Mon Sep 17 00:00:00 2001 From: chiroptical Date: Wed, 2 Apr 2025 12:23:52 -0400 Subject: [PATCH 5/5] Add some documentation pointing the unit tests for examples --- src/pg_timestamp.erl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pg_timestamp.erl b/src/pg_timestamp.erl index 1b8453e..c7be6ad 100644 --- a/src/pg_timestamp.erl +++ b/src/pg_timestamp.erl @@ -49,6 +49,9 @@ encode_timestamp(infinity) -> 16#7FFFFFFFFFFFFFFF; encode_timestamp('-infinity') -> -16#8000000000000000; +% A timestamp with a positive offset is a time in the future, compared to UTC, +% and therefore you need to subtract the hour and minutes to generate a UTC +% time. Please see 'test/timestamptz_tests.erl' for some examples. encode_timestamp({{Year, Month, Day}, {Hour, Minute, Seconds}, {HourOffset, MinuteOffset}}) when is_integer(Seconds), MinuteOffset >= 0 -> Sign = determine_sign(HourOffset), OffsetFromHours = calendar:time_to_seconds({abs(HourOffset), 0, 0}),