Skip to content

Commit 75c406d

Browse files
committed
Make timestamptz a time zone aware type for Postgres
rails#41395 added support for the `timestamptz` type on the Postgres adapter. As we found [here](rails#41084 (comment)) this causes issues because in some scenarios the new type is not considered a time zone aware attribute, meaning values of this type in the DB are presented as a `Time`, not an `ActiveSupport::TimeWithZone`. This PR fixes that by ensuring that `timestamptz` is always a time zone aware type, for Postgres users.
1 parent 81d6012 commit 75c406d

File tree

6 files changed

+151
-1
lines changed

6 files changed

+151
-1
lines changed

activerecord/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
* Add `timestamptz` as a time zone aware type for PostgreSQL
2+
3+
This is required for correctly parsing `timestamp with time zone` values in your database.
4+
5+
If you don't want this, you can opt out by adding this initializer:
6+
7+
```ruby
8+
ActiveRecord::Base.time_zone_aware_types -= [:timestamptz]
9+
```
10+
11+
*Alex Ghiculescu*
12+
113
* Add new `ActiveRecord::Base::generates_token_for` API.
214

315
Currently, `signed_id` fulfills the role of generating tokens for e.g.

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,5 +1095,6 @@ def decode(value, tuple = nil, field = nil)
10951095
ActiveRecord::Type.register(:vector, OID::Vector, adapter: :postgresql)
10961096
ActiveRecord::Type.register(:xml, OID::Xml, adapter: :postgresql)
10971097
end
1098+
ActiveSupport.run_load_hooks(:active_record_postgresqladapter, PostgreSQLAdapter)
10981099
end
10991100
end

activerecord/lib/active_record/railtie.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ class Railtie < Rails::Railtie # :nodoc:
7878
end
7979
end
8080

81+
initializer "active_record.postgresql_time_zone_aware_types" do
82+
ActiveSupport.on_load(:active_record_postgresqladapter) do
83+
ActiveRecord::Base.time_zone_aware_types << :timestamptz
84+
end
85+
end
86+
8187
initializer "active_record.logger" do
8288
ActiveSupport.on_load(:active_record) { self.logger ||= ::Rails.logger }
8389
end

activerecord/test/cases/adapters/postgresql/timestamp_test.rb

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class PostgresqlTimestampWithZone < ActiveRecord::Base; end
1818
PostgresqlTimestampWithZone.delete_all
1919
end
2020

21-
def test_timestamp_with_zone_values_with_rails_time_zone_support
21+
def test_timestamp_with_zone_values_with_rails_time_zone_support_and_no_time_zone_set
2222
with_timezone_config default: :utc, aware_attributes: true do
2323
@connection.reconnect!
2424

@@ -45,6 +45,78 @@ def test_timestamp_with_zone_values_without_rails_time_zone_support
4545
end
4646
end
4747

48+
class PostgresqlTimestampWithAwareTypesTest < ActiveRecord::PostgreSQLTestCase
49+
class PostgresqlTimestampWithZone < ActiveRecord::Base; end
50+
51+
self.use_transactional_tests = false
52+
53+
setup do
54+
@connection = ActiveRecord::Base.connection
55+
@connection.execute("INSERT INTO postgresql_timestamp_with_zones (id, time) VALUES (1, '2010-01-01 10:00:00-1')")
56+
end
57+
58+
teardown do
59+
PostgresqlTimestampWithZone.delete_all
60+
end
61+
62+
def test_timestamp_with_zone_values_with_rails_time_zone_support_and_time_zone_set
63+
with_timezone_config default: :utc, aware_attributes: true, zone: "Pacific Time (US & Canada)", aware_types: [:timestamptz, :datetime, :time] do
64+
@connection.reconnect!
65+
66+
timestamp = PostgresqlTimestampWithZone.find(1)
67+
assert_equal Time.utc(2010, 1, 1, 11, 0, 0), timestamp.time
68+
assert_instance_of ActiveSupport::TimeWithZone, timestamp.time
69+
end
70+
ensure
71+
@connection.reconnect!
72+
end
73+
end
74+
75+
class PostgresqlTimestampWithTimeZoneTest < ActiveRecord::PostgreSQLTestCase
76+
class PostgresqlTimestampWithZone < ActiveRecord::Base; end
77+
78+
self.use_transactional_tests = false
79+
80+
setup do
81+
with_postgresql_datetime_type(:timestamptz) do
82+
@connection = ActiveRecord::Base.connection
83+
@connection.execute("INSERT INTO postgresql_timestamp_with_zones (id, time) VALUES (1, '2010-01-01 10:00:00-1')")
84+
end
85+
end
86+
87+
teardown do
88+
PostgresqlTimestampWithZone.delete_all
89+
end
90+
91+
def test_timestamp_with_zone_values_with_rails_time_zone_support_and_timestamptz_and_no_time_zone_set
92+
with_postgresql_datetime_type(:timestamptz) do
93+
with_timezone_config default: :utc, aware_attributes: true, aware_types: [:timestamptz, :datetime, :time] do
94+
@connection.reconnect!
95+
96+
timestamp = PostgresqlTimestampWithZone.find(1)
97+
assert_equal Time.utc(2010, 1, 1, 11, 0, 0), timestamp.time
98+
assert_instance_of Time, timestamp.time
99+
end
100+
end
101+
ensure
102+
@connection.reconnect!
103+
end
104+
105+
def test_timestamp_with_zone_values_with_rails_time_zone_support_and_timestamptz_and_time_zone_set
106+
with_postgresql_datetime_type(:timestamptz) do
107+
with_timezone_config default: :utc, aware_attributes: true, zone: "Pacific Time (US & Canada)", aware_types: [:timestamptz, :datetime, :time] do
108+
@connection.reconnect!
109+
110+
timestamp = PostgresqlTimestampWithZone.find(1)
111+
assert_equal Time.utc(2010, 1, 1, 11, 0, 0), timestamp.time
112+
assert_instance_of ActiveSupport::TimeWithZone, timestamp.time
113+
end
114+
end
115+
ensure
116+
@connection.reconnect!
117+
end
118+
end
119+
48120
class PostgresqlTimestampFixtureTest < ActiveRecord::PostgreSQLTestCase
49121
fixtures :topics
50122

activerecord/test/cases/helper.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def with_timezone_config(cfg)
102102

103103
old_default_zone = ActiveRecord.default_timezone
104104
old_awareness = ActiveRecord::Base.time_zone_aware_attributes
105+
old_aware_types = ActiveRecord::Base.time_zone_aware_types
105106
old_zone = Time.zone
106107

107108
if cfg.has_key?(:default)
@@ -110,19 +111,24 @@ def with_timezone_config(cfg)
110111
if cfg.has_key?(:aware_attributes)
111112
ActiveRecord::Base.time_zone_aware_attributes = cfg[:aware_attributes]
112113
end
114+
if cfg.has_key?(:aware_types)
115+
ActiveRecord::Base.time_zone_aware_types = cfg[:aware_types]
116+
end
113117
if cfg.has_key?(:zone)
114118
Time.zone = cfg[:zone]
115119
end
116120
yield
117121
ensure
118122
ActiveRecord.default_timezone = old_default_zone
119123
ActiveRecord::Base.time_zone_aware_attributes = old_awareness
124+
ActiveRecord::Base.time_zone_aware_types = old_aware_types
120125
Time.zone = old_zone
121126
end
122127

123128
# This method makes sure that tests don't leak global state related to time zones.
124129
EXPECTED_ZONE = nil
125130
EXPECTED_DEFAULT_TIMEZONE = :utc
131+
EXPECTED_AWARE_TYPES = [:datetime, :time]
126132
EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES = false
127133
def verify_default_timezone_config
128134
if Time.zone != EXPECTED_ZONE
@@ -149,6 +155,14 @@ def verify_default_timezone_config
149155
Got: #{ActiveRecord::Base.time_zone_aware_attributes}
150156
MSG
151157
end
158+
if ActiveRecord::Base.time_zone_aware_types != EXPECTED_AWARE_TYPES
159+
$stderr.puts <<-MSG
160+
\n#{self}
161+
Global state `ActiveRecord::Base.time_zone_aware_types` was leaked.
162+
Expected: #{EXPECTED_AWARE_TYPES}
163+
Got: #{ActiveRecord::Base.time_zone_aware_types}
164+
MSG
165+
end
152166
end
153167

154168
def enable_extension!(extension, connection)

railties/test/application/configuration_test.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3974,6 +3974,51 @@ def new(app); self; end
39743974
assert_equal ActiveSupport::Cache::Coders::Rails61Coder, Rails.cache.instance_variable_get(:@coder)
39753975
end
39763976

3977+
test "adds a time zone aware type if using PostgreSQL" do
3978+
original_configurations = ActiveRecord::Base.configurations
3979+
ActiveRecord::Base.configurations = { production: { db1: { adapter: "postgresql" } } }
3980+
app_file "config/initializers/active_record.rb", <<~RUBY
3981+
ActiveRecord::Base.establish_connection(adapter: "postgresql")
3982+
RUBY
3983+
3984+
app "production"
3985+
3986+
assert_equal [:datetime, :time, :timestamptz], ActiveRecord::Base.time_zone_aware_types
3987+
ensure
3988+
ActiveRecord::Base.configurations = original_configurations
3989+
end
3990+
3991+
test "doesn't add a time zone aware type if using MySQL" do
3992+
original_configurations = ActiveRecord::Base.configurations
3993+
ActiveRecord::Base.configurations = { production: { db1: { adapter: "mysql2" } } }
3994+
app_file "config/initializers/active_record.rb", <<~RUBY
3995+
ActiveRecord::Base.establish_connection(adapter: "mysql2")
3996+
RUBY
3997+
3998+
app "production"
3999+
4000+
assert_equal [:datetime, :time], ActiveRecord::Base.time_zone_aware_types
4001+
ensure
4002+
ActiveRecord::Base.configurations = original_configurations
4003+
end
4004+
4005+
test "can opt out of extra time zone aware types if using PostgreSQL" do
4006+
original_configurations = ActiveRecord::Base.configurations
4007+
ActiveRecord::Base.configurations = { production: { db1: { adapter: "postgresql" } } }
4008+
app_file "config/initializers/active_record.rb", <<~RUBY
4009+
ActiveRecord::Base.establish_connection(adapter: "postgresql")
4010+
RUBY
4011+
app_file "config/initializers/tz_aware_types.rb", <<~RUBY
4012+
ActiveRecord::Base.time_zone_aware_types -= [:timestamptz]
4013+
RUBY
4014+
4015+
app "production"
4016+
4017+
assert_equal [:datetime, :time], ActiveRecord::Base.time_zone_aware_types
4018+
ensure
4019+
ActiveRecord::Base.configurations = original_configurations
4020+
end
4021+
39774022
private
39784023
def set_custom_config(contents, config_source = "custom".inspect)
39794024
app_file "config/custom.yml", contents

0 commit comments

Comments
 (0)