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

In https://github.com/rails/rails/issues/21126 it was suggested to make "timestamp with time zone" the default type for datetime columns in PostgreSQL. This is in line with PostgreSQL [best practices](https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_timestamp_.28without_time_zone.29). This PR lays some groundwork for that.

This PR adds a configuration option, `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type`. The default is `:timestamp` which preserves current Rails behavior of using "timestamp without time zone" when you do `t.datetime` in a migration. If you change it to `:timestamptz`, you'll get "timestamp with time zone" columns instead.

If you change this setting in an existing app, you should immediately call `bin/rails db:migrate` to ensure your `schema.rb` file remains correct. If you do so, then existing columns will not be impacted, so for example if you have an app with a mixture of both types of columns, and you change the config, schema dumps will continue to output the correct types.

This PR also adds two new types that can be used in migrations: `t.timestamp` and `t.timestamptz`.

```ruby
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamp # default value is :timestamp

create_table("foo1") do |t|
  t.datetime :default_format # "timestamp without time zone"
  t.timestamp :without_time_zone # "timestamp without time zone"
  t.timestamptz :with_time_zone # "timestamp with time zone"
end

ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz

create_table("foo2") do |t|
  t.datetime :default_format # "timestamp with time zone" <-- note how this has changed!
  t.timestamp :without_time_zone # "timestamp without time zone"
  t.timestamptz :with_time_zone # "timestamp with time zone"
end

ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:my_custom_type] = { name: "custom_datetime_format_i_invented" }
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :my_custom_type

create_table("foo3") do |t|
  t.datetime :default_format # "custom_datetime_format_i_invented"
  t.timestamp :without_time_zone # "timestamp without time zone"
  t.timestamptz :with_time_zone # "timestamp with time zone"
end
```

**Notes**

- This PR doesn't change the default `datetime` format. The default is still "timestamp without time zone". A future PR could do that, but there was enough code here just getting the config option right.
- See also https://github.com/rails/rails/pull/41395 which set some groundwork (and added some tests) for this.
- This reverts some of https://github.com/rails/rails/pull/15184. https://github.com/rails/rails/pull/15184 alluded to issues in XML serialization, but I couldn't find any related tests that this broke.
This commit is contained in:
Alex Ghiculescu 2021-03-09 13:29:07 -06:00
parent d89d14d241
commit 867d27dd64
16 changed files with 537 additions and 7 deletions

@ -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`,

@ -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

@ -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

@ -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

@ -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

@ -98,6 +98,24 @@ 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" },
@ -105,7 +123,8 @@ def new_client(conn_params)
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" },
@ -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
@ -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

@ -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

@ -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)
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

@ -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" }

@ -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

@ -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

@ -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

@ -290,6 +290,39 @@ def test_add_column_with_timestamp_type
end
end
def test_add_column_with_postgresql_datetime_type
connection.create_table :testings do |t|
t.column :foo, :datetime
end
column = connection.columns(:testings).find { |c| c.name == "foo" }
assert_equal :datetime, column.type
if current_adapter?(:PostgreSQLAdapter)
assert_equal "timestamp without time zone", column.sql_type
elsif current_adapter?(:Mysql2Adapter)
assert_equal "datetime", column.sql_type
else
assert_equal connection.type_to_sql("datetime"), column.sql_type
end
end
if current_adapter?(:PostgreSQLAdapter)
def test_add_column_with_datetime_in_timestamptz_mode
with_postgresql_datetime_type(:timestamptz) do
connection.create_table :testings do |t|
t.column :foo, :datetime
end
column = connection.columns(:testings).find { |c| c.name == "foo" }
assert_equal :datetime, column.type
assert_equal "timestamp with time zone", column.sql_type
end
end
end
def test_change_column_with_timestamp_type
connection.create_table :testings do |t|
t.column :foo, :datetime, null: false

@ -502,6 +502,125 @@ def change
def test_schema_dump_with_correct_timestamp_types_via_create_table_and_t_column
original, $stdout = $stdout, StringIO.new
migration = Class.new(ActiveRecord::Migration::Current) do
def up
create_table("timestamps") do |t|
t.datetime :this_should_remain_datetime
t.timestamp :this_is_an_alias_of_datetime
t.column :without_time_zone, :timestamp
t.column :with_time_zone, :timestamptz
end
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
assert output.include?('t.datetime "this_should_remain_datetime"')
assert output.include?('t.datetime "this_is_an_alias_of_datetime"')
assert output.include?('t.datetime "without_time_zone"')
assert output.include?('t.timestamptz "with_time_zone"')
ensure
migration.migrate(:down)
$stdout = original
end
def test_schema_dump_with_timestamptz_datetime_format
migration, original, $stdout = nil, $stdout, StringIO.new
with_postgresql_datetime_type(:timestamptz) do
migration = Class.new(ActiveRecord::Migration::Current) do
def up
create_table("timestamps") do |t|
t.datetime :this_should_remain_datetime
t.timestamptz :this_is_an_alias_of_datetime
t.column :without_time_zone, :timestamp
t.column :with_time_zone, :timestamptz
end
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
assert output.include?('t.datetime "this_should_remain_datetime"')
assert output.include?('t.datetime "this_is_an_alias_of_datetime"')
assert output.include?('t.timestamp "without_time_zone"')
assert output.include?('t.datetime "with_time_zone"')
end
ensure
migration.migrate(:down)
$stdout = original
end
def test_timestamps_schema_dump_before_rails_7
migration, original, $stdout = nil, $stdout, StringIO.new
migration = Class.new(ActiveRecord::Migration[6.1]) do
def up
create_table("timestamps") do |t|
t.datetime :this_should_remain_datetime
t.timestamp :this_is_an_alias_of_datetime
t.column :this_is_also_an_alias_of_datetime, :timestamp
end
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
assert output.include?('t.datetime "this_should_remain_datetime"')
assert output.include?('t.datetime "this_is_an_alias_of_datetime"')
assert output.include?('t.datetime "this_is_also_an_alias_of_datetime"')
ensure
migration.migrate(:down)
$stdout = original
end
def test_timestamps_schema_dump_before_rails_7_with_timestamptz_setting
migration, original, $stdout = nil, $stdout, StringIO.new
with_postgresql_datetime_type(:timestamptz) do
migration = Class.new(ActiveRecord::Migration[6.1]) do
def up
create_table("timestamps") do |t|
t.datetime :this_should_change_to_timestamp
t.timestamp :this_should_stay_as_timestamp
t.column :this_should_also_stay_as_timestamp, :timestamp
end
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
# Normally we'd write `t.datetime` here. But because you've changed the `datetime_type`
# to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns
# are still created as a `:timestamp` we need to change what is written to the schema dump.
#
# Typically in Rails we handle this through Migration versioning (`ActiveRecord::Migration::Compatibility`)
# but that doesn't work here because the schema dumper is not aware of which migration
# a column was added in.
assert output.include?('t.timestamp "this_should_change_to_timestamp"')
assert output.include?('t.timestamp "this_should_stay_as_timestamp"')
assert output.include?('t.timestamp "this_should_also_stay_as_timestamp"')
end
ensure
migration.migrate(:down)
$stdout = original
end
def test_schema_dump_when_changing_datetime_type_for_an_existing_app
original, $stdout = $stdout, StringIO.new
migration = Class.new(ActiveRecord::Migration::Current) do
def up
create_table("timestamps") do |t|
@ -520,7 +639,16 @@ def down
assert output.include?('t.datetime "default_format"')
assert output.include?('t.datetime "without_time_zone"')
assert output.include?('t.timestamptz "with_time_zone"')
datetime_type_was = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
output = perform_schema_dump
assert output.include?('t.timestamp "default_format"')
assert output.include?('t.timestamp "without_time_zone"')
assert output.include?('t.datetime "with_time_zone"')
ensure
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = datetime_type_was
migration.migrate(:down)
$stdout = original
end
@ -533,6 +661,7 @@ def up
create_table("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
@ -545,6 +674,7 @@ def down
output = perform_schema_dump
assert output.include?('t.datetime "default_format"')
assert output.include?('t.datetime "without_time_zone"')
assert output.include?('t.datetime "also_without_time_zone"')
assert output.include?('t.timestamptz "with_time_zone"')
ensure
migration.migrate(:down)
@ -559,7 +689,8 @@ def up
create_table("timestamps")
add_column :timestamps, :default_format, :datetime
add_column :timestamps, :without_time_zone, :timestamp
add_column :timestamps, :without_time_zone, :datetime
add_column :timestamps, :also_without_time_zone, :timestamp
add_column :timestamps, :with_time_zone, :timestamptz
end
def down
@ -571,11 +702,104 @@ def down
output = perform_schema_dump
assert output.include?('t.datetime "default_format"')
assert output.include?('t.datetime "without_time_zone"')
assert output.include?('t.datetime "also_without_time_zone"')
assert output.include?('t.timestamptz "with_time_zone"')
ensure
migration.migrate(:down)
$stdout = original
end
def test_schema_dump_with_correct_timestamp_types_via_add_column_before_rails_7
original, $stdout = $stdout, StringIO.new
migration = Class.new(ActiveRecord::Migration[6.1]) do
def up
create_table("timestamps")
add_column :timestamps, :default_format, :datetime
add_column :timestamps, :without_time_zone, :datetime
add_column :timestamps, :also_without_time_zone, :timestamp
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
assert output.include?('t.datetime "default_format"')
assert output.include?('t.datetime "without_time_zone"')
assert output.include?('t.datetime "also_without_time_zone"')
ensure
migration.migrate(:down)
$stdout = original
end
def test_schema_dump_with_correct_timestamp_types_via_add_column_before_rails_7_with_timestamptz_setting
migration, original, $stdout = nil, $stdout, StringIO.new
with_postgresql_datetime_type(:timestamptz) do
migration = Class.new(ActiveRecord::Migration[6.1]) do
def up
create_table("timestamps")
add_column :timestamps, :this_should_change_to_timestamp, :datetime
add_column :timestamps, :this_should_stay_as_timestamp, :timestamp
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
# Normally we'd write `t.datetime` here. But because you've changed the `datetime_type`
# to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns
# are still created as a `:timestamp` we need to change what is written to the schema dump.
#
# Typically in Rails we handle this through Migration versioning (`ActiveRecord::Migration::Compatibility`)
# but that doesn't work here because the schema dumper is not aware of which migration
# a column was added in.
assert output.include?('t.timestamp "this_should_change_to_timestamp"')
assert output.include?('t.timestamp "this_should_stay_as_timestamp"')
end
ensure
migration.migrate(:down)
$stdout = original
end
def test_schema_dump_with_correct_timestamp_types_via_add_column_with_type_as_string
migration, original, $stdout = nil, $stdout, StringIO.new
with_postgresql_datetime_type(:timestamptz) do
migration = Class.new(ActiveRecord::Migration[6.1]) do
def up
create_table("timestamps")
add_column :timestamps, :this_should_change_to_timestamp, "datetime"
add_column :timestamps, :this_should_stay_as_timestamp, "timestamp"
end
def down
drop_table("timestamps")
end
end
migration.migrate(:up)
output = perform_schema_dump
# Normally we'd write `t.datetime` here. But because you've changed the `datetime_type`
# to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns
# are still created as a `:timestamp` we need to change what is written to the schema dump.
#
# Typically in Rails we handle this through Migration versioning (`ActiveRecord::Migration::Compatibility`)
# but that doesn't work here because the schema dumper is not aware of which migration
# a column was added in.
assert output.include?('t.timestamp "this_should_change_to_timestamp"')
assert output.include?('t.timestamp "this_should_stay_as_timestamp"')
end
ensure
migration.migrate(:down)
$stdout = original
end
end
end

@ -101,6 +101,18 @@ def reset_callbacks(klass, kind)
subclass.send("_#{kind}_callbacks=", old_callbacks[subclass])
end
end
def with_postgresql_datetime_type(type)
adapter = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
adapter.remove_instance_variable(:@native_database_types) if adapter.instance_variable_defined?(:@native_database_types)
datetime_type_was = adapter.datetime_type
adapter.datetime_type = type
yield
ensure
adapter = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
adapter.datetime_type = datetime_type_was
adapter.remove_instance_variable(:@native_database_types) if adapter.instance_variable_defined?(:@native_database_types)
end
end
class PostgreSQLTestCase < TestCase

@ -479,7 +479,7 @@ The MySQL adapter adds one additional configuration option:
* `ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans` controls whether Active Record will consider all `tinyint(1)` columns as booleans. Defaults to `true`.
The PostgreSQL adapter adds one additional configuration option:
The PostgreSQL adapter adds two additional configuration options:
* `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables`
controls whether database tables created should be "unlogged," which can speed
@ -487,6 +487,14 @@ The PostgreSQL adapter adds one additional configuration option:
highly recommended that you do not enable this in a production environment.
Defaults to `false` in all environments.
* `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type`
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.
The schema dumper adds two additional configuration options:
* `ActiveRecord::SchemaDumper.ignore_tables` accepts an array of tables that should _not_ be included in any generated schema file.