Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Active Record + PostgreSQL: native support for timestamp with time zone #41084

Merged
merged 1 commit into from
May 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,18 @@

*Josua Schmid*

* PostgreSQL: introduce `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type`

This setting controls what native type Active Record should use when you call `datetime` in
a migration or schema. It takes a symbol which must correspond to one of the configured
`NATIVE_DATABASE_TYPES`. The default is `:timestamp`, meaning `t.datetime` in a migration
will create a "timestamp without time zone" column. To use "timestamp with time zone",
change this to `:timestamptz` in an initializer.

You should run `bin/rails db:migrate` to rebuild your schema.rb if you change this.

*Alex Ghiculescu*

* PostgreSQL: handle `timestamp with time zone` columns correctly in `schema.rb`.

Previously they dumped as `t.datetime :column_name`, now they dump as `t.timestamptz :column_name`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ def type_cast_for_schema(value)
else super
end
end

protected
def real_type_unless_aliased(real_type)
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type == real_type ? :datetime : real_type
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ module ConnectionAdapters
module PostgreSQL
module OID # :nodoc:
class Timestamp < DateTime # :nodoc:
def type
real_type_unless_aliased(:timestamp)
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,22 @@ module ActiveRecord
module ConnectionAdapters
module PostgreSQL
module OID # :nodoc:
class TimestampWithTimeZone < Timestamp # :nodoc:
class TimestampWithTimeZone < DateTime # :nodoc:
def type
:timestamptz
real_type_unless_aliased(:timestamptz)
end

def cast_value(value)
time = super
return time if time.is_a?(ActiveSupport::TimeWithZone)

# While in UTC mode, the PG gem may not return times back in "UTC" even if they were provided to Postgres in UTC.
# We prefer times always in UTC, so here we convert back.
if is_utc?
time.getutc
else
time.getlocal
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ def initialize(*, **)
end

private
def aliased_types(name, fallback)
fallback
end

def integer_like_primary_key_type(type, options)
if type == :bigint || options[:limit] == 8
:bigserial
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,33 @@ def new_client(conn_params)
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true
class_attribute :create_unlogged_tables, default: false

##
# :singleton-method:
# PostgreSQL supports multiple types for DateTimes. By default if you use `datetime`
# in migrations, Rails will translate this to a PostgreSQL "timestamp without time zone".
# Change this in an initializer to use another NATIVE_DATABASE_TYPES. For example, to
# store DateTimes as "timestamp with time zone":
#
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
#
# Or if you are adding a custom type:
#
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:my_custom_type] = { name: "my_custom_type_name" }
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :my_custom_type
#
# If you're using :ruby as your config.active_record.schema_format and you change this
# setting, you should immediately run bin/rails db:migrate to update the types in your schema.rb.
class_attribute :datetime_type, default: :timestamp

NATIVE_DATABASE_TYPES = {
primary_key: "bigserial primary key",
string: { name: "character varying" },
text: { name: "text" },
integer: { name: "integer", limit: 4 },
float: { name: "float" },
decimal: { name: "decimal" },
datetime: { name: "timestamp" },
datetime: {}, # set dynamically based on datetime_type
timestamp: { name: "timestamp" },
timestamptz: { name: "timestamptz" },
time: { name: "time" },
date: { name: "date" },
Expand Down Expand Up @@ -319,7 +338,15 @@ def discard! # :nodoc:
end

def native_database_types #:nodoc:
NATIVE_DATABASE_TYPES
self.class.native_database_types
end

def self.native_database_types #:nodoc:
@native_database_types ||= begin
types = NATIVE_DATABASE_TYPES.dup
types[:datetime] = types[datetime_type]
types
end
end

def set_standard_conforming_strings
Expand Down Expand Up @@ -889,7 +916,12 @@ def update_typemap_for_default_timezone

@timestamp_decoder = decoder_class.new(@timestamp_decoder.to_h)
@connection.type_map_for_results.add_coder(@timestamp_decoder)

@default_timezone = ActiveRecord::Base.default_timezone

# if default timezone has changed, we need to reconfigure the connection
# (specifically, the session time zone)
configure_connection
end
end

Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def self.configurations
##
# :singleton-method:
# Specify whether schema dump should happen at the end of the
# db:migrate rails command. This is true by default, which is useful for the
# bin/rails db:migrate command. This is true by default, which is useful for the
# development environment. This should ideally be false in the production
# environment where dumping schema is rarely needed.
mattr_accessor :dump_schema_after_migration, instance_writer: false, default: true
Expand Down
41 changes: 41 additions & 0 deletions activerecord/lib/active_record/migration/compatibility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@ def self.find(version)
V7_0 = Current

class V6_1 < V7_0
class PostgreSQLCompat
def self.compatible_timestamp_type(type, connection)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pixeltrix @kamipo I've implemented the migration versioning here, tests are in activerecord/test/cases/schema_dumper_test.rb.

I think it's working correctly and tested for all scenarios:

  • Migration created in Rails 6.1, using default value for datetime_type -> no changes to schema.rb; t.datetime columns will continue to say t.datetime. The tests added for this pass without changes on main.
  • Migration created in Rails 6.1, with datetime_type changed to :timestamptz in an initializer -> if the migration said t.datetime, schema.rb will be changed to say t.timestamp. See comments + assertions here for why this is necessary.
  • Migration created in Rails 7+, using default value for datetime_type -> t.datetime columns say t.datetime in schema.rb. t.timestamp columns say t.datetime in schema.rb. t.timestamptz columns say t.timestamptz in schema.rb.
  • Migration created in Rails 6.1, with datetime_type changed to :timestamptz -> t.datetime columns say t.datetime in schema.rb. t.timestamp columns say t.timestamp in schema.rb. t.timestamptz columns say t.datetime in schema.rb.

if connection.adapter_name == "PostgreSQL"
# For Rails <= 6.1, :datetime was aliased to :timestamp
# See: https://github.com/rails/rails/blob/v6.1.3.2/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L108
# From Rails 7 onwards, you can define what :datetime resolves to (the default is still :timestamp)
# See `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type`
type.to_sym == :datetime ? :timestamp : type
else
type
end
end
end

def add_column(table_name, column_name, type, **options)
type = PostgreSQLCompat.compatible_timestamp_type(type, connection)
super
end

def create_table(table_name, **options)
if block_given?
super { |t| yield compatible_table_definition(t) }
else
super
end
end

module TableDefinition
def new_column_definition(name, type, **options)
type = PostgreSQLCompat.compatible_timestamp_type(type, @conn)
super
end
end

private
def compatible_table_definition(t)
class << t
prepend TableDefinition
end
t
end
end

class V6_0 < V6_1
Expand Down
31 changes: 31 additions & 0 deletions activerecord/test/cases/adapters/postgresql/change_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,37 @@ def test_change_type_with_symbol
assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end

def test_change_type_with_symbol_with_timestamptz
connection.change_column :strings, :somedate, :timestamptz, cast_as: :timestamptz
assert_equal :timestamptz, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end

def test_change_type_with_symbol_using_datetime
connection.change_column :strings, :somedate, :datetime, cast_as: :datetime
assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end

def test_change_type_with_symbol_using_timestamp_with_timestamptz_as_default
with_postgresql_datetime_type(:timestamptz) do
connection.change_column :strings, :somedate, :timestamp, cast_as: :timestamp
assert_equal :timestamp, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end
end

def test_change_type_with_symbol_with_timestamptz_as_default
with_postgresql_datetime_type(:timestamptz) do
connection.change_column :strings, :somedate, :timestamptz, cast_as: :timestamptz
assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end
end

def test_change_type_with_symbol_using_datetime_with_timestamptz_as_default
with_postgresql_datetime_type(:timestamptz) do
connection.change_column :strings, :somedate, :datetime, cast_as: :datetime
assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type
end
end

def test_change_type_with_array
connection.change_column :strings, :somedate, :timestamp, array: true, cast_as: :timestamp
column = connection.columns(:strings).find { |c| c.name == "somedate" }
Expand Down
45 changes: 45 additions & 0 deletions activerecord/test/cases/adapters/postgresql/timestamp_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,48 @@ def test_bc_timestamp_year_zero
assert_equal date, Developer.find_by_name("yahagi").updated_at
end
end

class PostgresqlTimestampMigrationTest < ActiveRecord::PostgreSQLTestCase
class PostgresqlTimestampWithZone < ActiveRecord::Base; end

def test_adds_column_as_timestamp
original, $stdout = $stdout, StringIO.new

ActiveRecord::Migration.new.add_column :postgresql_timestamp_with_zones, :times, :datetime

assert_equal({ "data_type" => "timestamp without time zone" },
PostgresqlTimestampWithZone.connection.execute("select data_type from information_schema.columns where column_name = 'times'").to_a.first)
ensure
$stdout = original
end

def test_adds_column_as_timestamptz_if_datetime_type_changed
original, $stdout = $stdout, StringIO.new

with_postgresql_datetime_type(:timestamptz) do
ActiveRecord::Migration.new.add_column :postgresql_timestamp_with_zones, :times, :datetime

assert_equal({ "data_type" => "timestamp with time zone" },
PostgresqlTimestampWithZone.connection.execute("select data_type from information_schema.columns where column_name = 'times'").to_a.first)
end
ensure
$stdout = original
end

def test_adds_column_as_custom_type
original, $stdout = $stdout, StringIO.new

PostgresqlTimestampWithZone.connection.execute("CREATE TYPE custom_time_format AS ENUM ('past', 'present', 'future');")

ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:datetimes_as_enum] = { name: "custom_time_format" }
with_postgresql_datetime_type(:datetimes_as_enum) do
ActiveRecord::Migration.new.add_column :postgresql_timestamp_with_zones, :times, :datetime

assert_equal({ "data_type" => "USER-DEFINED", "udt_name" => "custom_time_format" },
PostgresqlTimestampWithZone.connection.execute("select data_type, udt_name from information_schema.columns where column_name = 'times'").to_a.first)
end
ensure
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES.delete(:datetimes_as_enum)
$stdout = original
end
end
2 changes: 2 additions & 0 deletions activerecord/test/cases/ar_schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,14 @@ def test_timestamps_with_and_without_zones
create_table :has_timestamps do |t|
t.datetime "default_format"
t.datetime "without_time_zone"
t.timestamp "also_without_time_zone"
t.timestamptz "with_time_zone"
end
end

assert @connection.column_exists?(:has_timestamps, :default_format, :datetime)
assert @connection.column_exists?(:has_timestamps, :without_time_zone, :datetime)
assert @connection.column_exists?(:has_timestamps, :also_without_time_zone, :datetime)
assert @connection.column_exists?(:has_timestamps, :with_time_zone, :timestamptz)
end
end
Expand Down
65 changes: 65 additions & 0 deletions activerecord/test/cases/date_time_precision_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,77 @@ def test_formatting_datetime_according_to_precision

assert foo = Foo.find_by(created_at: date)
assert_equal 1, Foo.where(updated_at: date).count
assert_equal date.to_i, foo.created_at.to_i
assert_equal date.to_s, foo.created_at.to_s
assert_equal date.to_s, foo.updated_at.to_s
assert_equal 000000, foo.created_at.usec
assert_equal 999900, foo.updated_at.usec
end

def test_formatting_datetime_according_to_precision_when_time_zone_aware
with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do
@connection.create_table(:foos, force: true) do |t|
t.datetime :created_at, precision: 0
t.datetime :updated_at, precision: 4
end

date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999)
Foo.create!(created_at: date, updated_at: date)

assert foo = Foo.find_by(created_at: date)
assert_equal 1, Foo.where(updated_at: date).count
assert_equal date.to_i, foo.created_at.to_i
assert_equal date.in_time_zone.to_s, foo.created_at.to_s
assert_equal date.in_time_zone.to_s, foo.updated_at.to_s
assert_equal 000000, foo.created_at.usec
assert_equal 999900, foo.updated_at.usec
end
end

if current_adapter?(:PostgreSQLAdapter)
def test_formatting_datetime_according_to_precision_using_timestamptz
with_postgresql_datetime_type(:timestamptz) do
@connection.create_table(:foos, force: true) do |t|
t.datetime :created_at, precision: 0
t.datetime :updated_at, precision: 4
end

date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999)
Foo.create!(created_at: date, updated_at: date)

assert foo = Foo.find_by(created_at: date)
assert_equal 1, Foo.where(updated_at: date).count
assert_equal date.to_i, foo.created_at.to_i
assert_equal date.to_s, foo.created_at.to_s
assert_equal date.to_s, foo.updated_at.to_s
assert_equal 000000, foo.created_at.usec
assert_equal 999900, foo.updated_at.usec
end
end

def test_formatting_datetime_according_to_precision_when_time_zone_aware_using_timestamptz
with_postgresql_datetime_type(:timestamptz) do
with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do
@connection.create_table(:foos, force: true) do |t|
t.datetime :created_at, precision: 0
t.datetime :updated_at, precision: 4
end

date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999)
Foo.create!(created_at: date, updated_at: date)

assert foo = Foo.find_by(created_at: date)
assert_equal 1, Foo.where(updated_at: date).count
assert_equal date.to_i, foo.created_at.to_i
assert_equal date.in_time_zone.to_s, foo.created_at.to_s
assert_equal date.in_time_zone.to_s, foo.updated_at.to_s
assert_equal 000000, foo.created_at.usec
assert_equal 999900, foo.updated_at.usec
end
end
end
end

def test_schema_dump_includes_datetime_precision
@connection.create_table(:foos, force: true) do |t|
t.timestamps precision: 6
Expand Down