From 68b357dc56fd284ea503f2928a62c72bba5414ad Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sat, 8 Aug 2026 16:30:43 +0200 Subject: [PATCH] Respect calendar type of Ruby and PostgreSQL PostgreSQL uses gregorian calendar for all dates. It is described here: https://www.postgresql.org/docs/10/datetime-units-history.html Ruby treats dates before 1852-10-05 as julian calendar and after 1582-10-14 as gregorian calendar. It is described here: https://docs.ruby-lang.org/en/master/language/calendars_rdoc.html#argument-start In order to encode and decode the correct date, this PR encodes Date values send to the server as gregorian calendar. Non-gregorian dates are converted to gregorian calender before sent. The other way around, Date values decoded from the server are explicit interpret and labeled as gregorian calendar. The new behavior could be surprising when a julian date is passed through the server and changed to gregorian like so. But it is how the two parties represent one and the same date: ```ruby conn.exec_params("SELECT $1::date", [Date.new(1581, 5, 15)]).values => [[#]] ``` So far the PG::BasicTypeMapForQueries didn't use the Date encoder. This was because it didn't have an advantage over standard `to_s` conversion. Now that the encoder ensures that the date is sent as gregorian calender value, it makes sense to enable it for query parameters. Fixes #725 --- ext/pg_binary_decoder.c | 7 +++++-- ext/pg_binary_encoder.c | 7 +++++++ lib/pg/basic_type_map_for_queries.rb | 2 ++ lib/pg/text_decoder/date.rb | 2 +- lib/pg/text_encoder/date.rb | 8 +++++++- spec/pg/basic_type_map_based_on_result_spec.rb | 10 +++++----- spec/pg/basic_type_map_for_results_spec.rb | 7 +++++-- 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/ext/pg_binary_decoder.c b/ext/pg_binary_decoder.c index 6b6c06e30..18f07d08d 100644 --- a/ext/pg_binary_decoder.c +++ b/ext/pg_binary_decoder.c @@ -13,6 +13,7 @@ VALUE rb_mPG_BinaryDecoder; static VALUE s_Date; +static VALUE s_Date_GREGORIAN; /* Date::GREGORIAN */ static ID s_id_new; @@ -403,7 +404,7 @@ pg_bin_dec_date(t_pg_coder *conv, const char *val, int len, int tuple, int field default: j2date(date + POSTGRES_EPOCH_JDATE, &year, &month, &day); - return rb_funcall(s_Date, s_id_new, 3, INT2NUM(year), INT2NUM(month), INT2NUM(day)); + return rb_funcall(s_Date, s_id_new, 4, INT2NUM(year), INT2NUM(month), INT2NUM(day), s_Date_GREGORIAN); } } @@ -412,8 +413,10 @@ static VALUE init_pg_bin_decoder_date(VALUE rb_mPG_BinaryDecoder) { rb_require("date"); + rb_gc_register_address(&s_Date); + rb_gc_register_address(&s_Date_GREGORIAN); s_Date = rb_const_get(rb_cObject, rb_intern("Date")); - rb_gc_register_mark_object(s_Date); + s_Date_GREGORIAN = rb_const_get(s_Date, rb_intern("GREGORIAN")); s_id_new = rb_intern("new"); /* dummy = rb_define_class_under( rb_mPG_BinaryDecoder, "Date", rb_cPG_SimpleDecoder ); */ diff --git a/ext/pg_binary_encoder.c b/ext/pg_binary_encoder.c index 9417fe8b3..df8ba854b 100644 --- a/ext/pg_binary_encoder.c +++ b/ext/pg_binary_encoder.c @@ -11,6 +11,8 @@ #endif VALUE rb_mPG_BinaryEncoder; +static ID s_id_gregorianP; +static ID s_id_gregorian; static ID s_id_year; static ID s_id_month; static ID s_id_day; @@ -272,6 +274,9 @@ pg_bin_enc_date(t_pg_coder *this, VALUE value, char *out, VALUE *intermediate, i write_nbo32(PG_INT32_MIN, out); return 4; } { + /* Only create a new gregorian Date object if necessary */ + if( rb_funcall(value, s_id_gregorianP, 0) != Qtrue ) + value = rb_funcall(value, s_id_gregorian, 0); VALUE year = rb_funcall(value, s_id_year, 0); VALUE month = rb_funcall(value, s_id_month, 0); VALUE day = rb_funcall(value, s_id_day, 0); @@ -554,6 +559,8 @@ pg_bin_enc_from_base64(t_pg_coder *conv, VALUE value, char *out, VALUE *intermed void init_pg_binary_encoder(void) { + s_id_gregorianP = rb_intern("gregorian?"); + s_id_gregorian = rb_intern("gregorian"); s_id_year = rb_intern("year"); s_id_month = rb_intern("month"); s_id_day = rb_intern("day"); diff --git a/lib/pg/basic_type_map_for_queries.rb b/lib/pg/basic_type_map_for_queries.rb index 3b0d492d9..d7e9fc326 100644 --- a/lib/pg/basic_type_map_for_queries.rb +++ b/lib/pg/basic_type_map_for_queries.rb @@ -184,6 +184,7 @@ def get_array_type(value) Integer => [0, 'int8'], Float => [0, 'float8'], Time => [0, 'timestamptz'], + Date => [0, 'date'], # We use text format and no type OID for IPAddr, because setting the OID can lead # to unnecessary inet/cidr conversions on the server side. IPAddr => [0, 'inet'], @@ -200,6 +201,7 @@ def get_array_type(value) String => [0, '_text'], Float => [0, '_float8'], Time => [0, '_timestamptz'], + Date => [0, '_date'], IPAddr => [0, '_inet'], }.merge(has_bigdecimal ? {BigDecimal => [0, '_numeric']} : {})) private_constant :DEFAULT_ARRAY_TYPE_MAP diff --git a/lib/pg/text_decoder/date.rb b/lib/pg/text_decoder/date.rb index 75d0b6100..6fc4cf4a0 100644 --- a/lib/pg/text_decoder/date.rb +++ b/lib/pg/text_decoder/date.rb @@ -11,7 +11,7 @@ module TextDecoder class Date < SimpleDecoder def decode(string, tuple=nil, field=nil) if string =~ /\A(\d{4})-(\d\d)-(\d\d)\z/ - ::Date.new $1.to_i, $2.to_i, $3.to_i + ::Date.new $1.to_i, $2.to_i, $3.to_i, ::Date::GREGORIAN else string end diff --git a/lib/pg/text_encoder/date.rb b/lib/pg/text_encoder/date.rb index fa8b0d62c..a2f827560 100644 --- a/lib/pg/text_encoder/date.rb +++ b/lib/pg/text_encoder/date.rb @@ -6,7 +6,13 @@ module TextEncoder # This is a encoder class for conversion of Ruby Date values to PostgreSQL date type. class Date < SimpleEncoder def encode(value) - value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : value + if value.respond_to?(:gregorian?) + # Only create a new gregorian Date object if necessary + value = value.gregorian unless value.gregorian? + value.strftime("%Y-%m-%d") + else + value + end end end end diff --git a/spec/pg/basic_type_map_based_on_result_spec.rb b/spec/pg/basic_type_map_based_on_result_spec.rb index 0d0666f24..6e10554b9 100644 --- a/spec/pg/basic_type_map_based_on_result_spec.rb +++ b/spec/pg/basic_type_map_based_on_result_spec.rb @@ -127,7 +127,7 @@ [1, 0].each do |format| it "can type cast #copy_data input with encoder to format #{format}" do - @conn.exec( "CREATE TEMP TABLE copytable (b bytea, i INT, ts1 timestamp, ts2 timestamp, f4 float4, f8 float8, d1 date, d2 date)" ) + @conn.exec( "CREATE TEMP TABLE copytable (b bytea, i INT, ts1 timestamp, ts2 timestamp, f4 float4, f8 float8, d1 date, d2 date, d3 date)" ) # Retrieve table OIDs per empty result set. res = @conn.exec_params( "SELECT * FROM copytable LIMIT 0", [], format ) @@ -136,13 +136,13 @@ row_encoder = nsp::CopyRow.new type_map: tm @conn.copy_data( "COPY copytable FROM STDIN WITH (FORMAT #{ format==1 ? "binary" : "text" })", row_encoder ) do |res| - @conn.put_copy_data ["\xff\x00\n\r'", 123, Time.utc(2023, 3, 17, 3, 4, 5.6789123), Time.new(1990, 12, 17, 18, 44, 45, "+03:30").utc, 12.345, -12.345e167, Date.new(2055, 12, 31), Date.new(1234, 8, 31)] - @conn.put_copy_data [" xyz ", -444, "Infinity", "-infinity", -Float::INFINITY, Float::NAN, "infinity", "-infinity"] + @conn.put_copy_data ["\xff\x00\n\r'", 123, Time.utc(2023, 3, 17, 3, 4, 5.6789123), Time.new(1990, 12, 17, 18, 44, 45, "+03:30").utc, 12.345, -12.345e167, Date.new(2055, 12, 31), Date.new(1234, 8, 15), Date.new(1234, 8, 15, ::Date::GREGORIAN)] + @conn.put_copy_data [" xyz ", -444, "Infinity", "-infinity", -Float::INFINITY, Float::NAN, "infinity", "-infinity", nil] end res = @conn.exec( "SELECT * FROM copytable" ) expect( res.values ).to eq( [ - ["\\xff000a0d27", "123", "2023-03-17 03:04:05.678912", "1990-12-17 15:14:45", "12.345", "-1.2345e+168", "2055-12-31", "1234-08-31"], - ["\\x202078797a2020", "-444", "infinity", "-infinity", "-Infinity", "NaN", "infinity", "-infinity"] + ["\\xff000a0d27", "123", "2023-03-17 03:04:05.678912", "1990-12-17 15:14:45", "12.345", "-1.2345e+168", "2055-12-31", "1234-08-22", "1234-08-15"], + ["\\x202078797a2020", "-444", "infinity", "-infinity", "-Infinity", "NaN", "infinity", "-infinity", nil] ] ) end end diff --git a/spec/pg/basic_type_map_for_results_spec.rb b/spec/pg/basic_type_map_for_results_spec.rb index d3176c43c..d1cbe9e07 100644 --- a/spec/pg/basic_type_map_for_results_spec.rb +++ b/spec/pg/basic_type_map_for_results_spec.rb @@ -244,12 +244,15 @@ it "should do format #{format} date type conversions" do res = @conn.exec_params( "SELECT CAST('2113-12-31' AS DATE), CAST('1913-12-31' AS DATE), + CAST('1581-12-15' AS DATE), CAST('infinity' AS DATE), CAST('-infinity' AS DATE)", [], format ) expect( res.getvalue(0,0) ).to eq( Date.new(2113, 12, 31) ) expect( res.getvalue(0,1) ).to eq( Date.new(1913, 12, 31) ) - expect( res.getvalue(0,2) ).to eq( 'infinity' ) - expect( res.getvalue(0,3) ).to eq( '-infinity' ) + expect( res.getvalue(0,2) ).to eq( Date.new(1581, 12, 15, Date::GREGORIAN) ) + expect( res.getvalue(0,2) ).to eq( Date.new(1581, 12, 5, Date::JULIAN) ) + expect( res.getvalue(0,3) ).to eq( 'infinity' ) + expect( res.getvalue(0,4) ).to eq( '-infinity' ) end end