diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index cc2d42d64..dc4728efa 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -45,6 +45,7 @@ static ID s_id_Rational; static ID s_id_new; static ID s_id_utc; static ID s_id_getlocal; +static ID s_id_minus; static ID s_id_BigDecimal; static VALUE s_Date; @@ -654,6 +655,9 @@ static int64_t seconds_since_epoch(int year, int mon, int day, int hour, int min * * +PG::Coder::TIMESTAMP_APP_UTC+ : Return timestamp as UTC time (default) * * +PG::Coder::TIMESTAMP_APP_LOCAL+ : Return timestamp as local time * + * The +TIMESTAMP_APP_*+ flags also apply to timezone offsets of a day or more, + * which Ruby's Time can't carry (PostgreSQL accepts up to 167 hours). + * * Example: * deco = PG::TextDecoder::Timestamp.new(flags: PG::Coder::TIMESTAMP_DB_UTC | PG::Coder::TIMESTAMP_APP_LOCAL) * deco.decode("2000-01-01 00:00:00") # => 2000-01-01 01:00:00 +0100 @@ -706,6 +710,8 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i if ((str[0] == '+' || str[0] == '-') && rb_isdigit(str[1]) && rb_isdigit(str[2])) { tz_given = str[0] == '-' ? TZ_NEG : TZ_POS; tz_hour = str2_to_int(str+1); str += 3; + /* SET TIME ZONE accepts offsets up to 167 hours */ + if (tz_hour >= 10 && rb_isdigit(str[0])) tz_hour = tz_hour * 10 + char_to_digit(*str++); if (str[0] == ':' && rb_isdigit(str[1]) && rb_isdigit(str[2])) { @@ -728,11 +734,14 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i VALUE res; struct timespec ts; int gmt_offset = tz_hour * 3600 + tz_min * 60 + tz_sec; + /* Ruby's Time can't carry an offset of a day or more, so those values keep only their instant */ + int offset_kept; if (tz_given == TZ_NEG) { gmt_offset = - gmt_offset; } + offset_kept = tz_given && gmt_offset > -86400 && gmt_offset < 86400; /* Fast path for time conversion */ ts.tv_nsec = nsec; @@ -756,7 +765,7 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i int64_t time = seconds_since_epoch(year, mon, day, hour, min, sec) - gmt_offset; ts.tv_sec = (time_t)time; if (ts.tv_sec == time) { - return rb_time_timespec_new(&ts, tz_given ? gmt_offset : conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); + return rb_time_timespec_new(&ts, offset_kept ? gmt_offset : conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ? INT_MAX : INT_MAX-1); } } /* mktime() fails on some values with some libc implementations, @@ -768,7 +777,7 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i if (tz_given) { /* with timezone */ - gmt_offset_value = INT2NUM(gmt_offset); + gmt_offset_value = INT2NUM(offset_kept ? gmt_offset : 0); } else { /* without timezone */ gmt_offset_value = conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL ? Qnil : INT2NUM(0); @@ -785,16 +794,16 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i if (tz_given) { /* with timezone */ + if (offset_kept) return res; + res = rb_funcall(res, s_id_minus, 1, INT2NUM(gmt_offset)); + } else if( (conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL) && (conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { + /* without timezone */ return res; + } + if( conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ) { + return rb_funcall(res, s_id_getlocal, 0); } else { - /* without timezone */ - if( (conv->flags & PG_CODER_TIMESTAMP_DB_LOCAL) && (conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL) ) { - return res; - } else if( conv->flags & PG_CODER_TIMESTAMP_APP_LOCAL ) { - return rb_funcall(res, s_id_getlocal, 0); - } else { - return rb_funcall(res, s_id_utc, 0); - } + return rb_funcall(res, s_id_utc, 0); } } } @@ -1013,6 +1022,7 @@ init_pg_text_decoder(void) s_id_new = rb_intern("new"); s_id_utc = rb_intern("utc"); s_id_getlocal = rb_intern("getlocal"); + s_id_minus = rb_intern("-"); rb_gc_register_address(&s_nan); rb_gc_register_address(&s_pos_inf); diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 203567136..41b8a294f 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -157,6 +157,21 @@ def encode(value) expect( textdec_timestamptz.decode('1916-01-01 00:00:00-00:25:21') ). to be_within(0.000001).of( Time.new(1916, 1, 1, 0, 0, 0, "-00:25:21") ) end + it 'decodes timestamps with a timezone offset of a day or more' do + # SET TIME ZONE 30; -- PostgreSQL accepts offsets up to 167:59:59 + # SELECT '2024-01-01 12:00:00+00'::timestamptz; + # -- "2024-01-02 18:00:00+30" + instant = Time.utc(2024, 1, 1, 12, 0, 0.123456r) + ['2024-01-02 18:00:00.123456+30', '2024-01-02 12:00:00.123456+24', '2024-01-05 16:00:00.123456+100', + '2024-01-08 11:59:59.123456+167:59:59', '2023-12-31 11:59:59.123456-24:00:01'].each do |text| + expect( textdec_timestamptz.decode(text).iso8601(6) ).to eq( instant.iso8601(6) ) + expect( textdec_timestampul.decode(text).iso8601(6) ).to eq( instant.getlocal.iso8601(6) ) + end + end + it 'keeps a timezone offset just under a day' do + skip "TruffleRuby's Time doesn't accept this offset" if RUBY_ENGINE=="truffleruby" + expect( textdec_timestamptz.decode('2024-01-02 11:59:59+23:59:59').utc_offset ).to eq( 86399 ) + end it 'decodes timestamps with date before 1823' do expect( textdec_timestamp.decode('1822-01-02 23:23:59.123456').iso8601(5) ). to eq( Time.new(1822,01,02, 23, 23, 59.123456).iso8601(5) )