From e5a5cc483573f41fa396779057bd83ce389640d8 Mon Sep 17 00:00:00 2001 From: Andrey Novikov Date: Mon, 20 Jan 2020 19:26:41 +0300 Subject: [PATCH] Add support for PostgreSQL `interval` datatype. Add support for PostgreSQL `interval` data type with conversion to `ActiveSupport::Duration` when loading records from database and serialization to ISO 8601 formatted duration string on save. Add support to define a column in migrations and get it in a schema dump. Optional column precision is supported. To use this in 6.1, you need to place the next string to your model file: attribute :duration, :interval To keep old behavior until 6.2 is released: attribute :duration, :string --- activerecord/CHANGELOG.md | 33 ++++++ .../connection_adapters/postgresql/oid.rb | 1 + .../postgresql/oid/interval.rb | 49 +++++++++ .../postgresql/schema_statements.rb | 6 ++ .../connection_adapters/postgresql_adapter.rb | 14 ++- .../lib/active_record/model_schema.rb | 27 +++++ .../adapters/postgresql/datatype_test.rb | 14 +-- .../adapters/postgresql/interval_test.rb | 102 ++++++++++++++++++ guides/source/active_record_postgresql.md | 23 ++++ 9 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 activerecord/lib/active_record/connection_adapters/postgresql/oid/interval.rb create mode 100644 activerecord/test/cases/adapters/postgresql/interval_test.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 5dace15dc2..c51bd3daa4 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,36 @@ +* Add support for PostgreSQL `interval` data type with conversion to + `ActiveSupport::Duration` when loading records from database and + serialization to ISO 8601 formatted duration string on save. + Add support to define a column in migrations and get it in a schema dump. + Optional column precision is supported. + + To use this in 6.1, you need to place the next string to your model file: + + attribute :duration, :interval + + To keep old behavior until 6.2 is released: + + attribute :duration, :string + + Example: + + create_table :events do |t| + t.string :name + t.interval :duration + end + + class Event < ApplicationRecord + attribute :duration, :interval + end + + Event.create!(name: 'Rock Fest', duration: 2.days) + Event.last.duration # => 2 days + Event.last.duration.iso8601 # => "P2D" + Event.new(duration: 'P1DT12H3S').duration # => 1 day, 12 hours, and 3 seconds + Event.new(duration: '1 day') # Unknown value will be ignored and NULL will be written to database + + *Andrey Novikov* + * Allow associations supporting the `dependent:` key to take `dependent: :destroy_async`. ```ruby diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb index b70a88539d..1540b2ee28 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb @@ -11,6 +11,7 @@ require "active_record/connection_adapters/postgresql/oid/decimal" require "active_record/connection_adapters/postgresql/oid/enum" require "active_record/connection_adapters/postgresql/oid/hstore" require "active_record/connection_adapters/postgresql/oid/inet" +require "active_record/connection_adapters/postgresql/oid/interval" require "active_record/connection_adapters/postgresql/oid/jsonb" require "active_record/connection_adapters/postgresql/oid/macaddr" require "active_record/connection_adapters/postgresql/oid/money" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/interval.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/interval.rb new file mode 100644 index 0000000000..0cb686033b --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/interval.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "active_support/duration" + +module ActiveRecord + module ConnectionAdapters + module PostgreSQL + module OID # :nodoc: + class Interval < Type::Value # :nodoc: + def type + :interval + end + + def cast_value(value) + case value + when ::ActiveSupport::Duration + value + when ::String + begin + ::ActiveSupport::Duration.parse(value) + rescue ::ActiveSupport::Duration::ISO8601Parser::ParsingError + nil + end + else + super + end + end + + def serialize(value) + case value + when ::ActiveSupport::Duration + value.iso8601(precision: self.precision) + when ::Numeric + # Sometimes operations on Times returns just float number of seconds so we need to handle that. + # Example: Time.current - (Time.current + 1.hour) # => -3600.000001776 (Float) + value.seconds.iso8601(precision: self.precision) + else + super + end + end + + def type_cast_for_schema(value) + serialize(value).inspect + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index f9cb5613ce..9088df064e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -566,6 +566,12 @@ module ActiveRecord when 5..8; "bigint" else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead." end + when "interval" + case precision + when nil; "interval" + when 0..6; "interval(#{precision})" + else raise(ActiveRecordError, "No interval type has precision of #{precision}. The allowed range of precision is from 0 to 6") + end else super end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 7017412b07..adce0272f0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -561,11 +561,6 @@ module ActiveRecord m.register_type "polygon", OID::SpecializedString.new(:polygon) m.register_type "circle", OID::SpecializedString.new(:circle) - m.register_type "interval" do |_, _, sql_type| - precision = extract_precision(sql_type) - OID::SpecializedString.new(:interval, precision: precision) - end - register_class_with_precision m, "time", Type::Time register_class_with_precision m, "timestamp", OID::DateTime @@ -589,6 +584,11 @@ module ActiveRecord end end + m.register_type "interval" do |*args, sql_type| + precision = extract_precision(sql_type) + OID::Interval.new(precision: precision) + end + load_additional_types end @@ -794,6 +794,9 @@ module ActiveRecord end end + # Set interval output format to ISO 8601 for ease of parsing by ActiveSupport::Duration.parse + execute("SET intervalstyle = iso_8601", "SCHEMA") + # SET statements from :variables config hash # https://www.postgresql.org/docs/current/static/sql-set.html variables.map do |k, v| @@ -962,6 +965,7 @@ module ActiveRecord ActiveRecord::Type.register(:enum, OID::Enum, adapter: :postgresql) ActiveRecord::Type.register(:hstore, OID::Hstore, adapter: :postgresql) ActiveRecord::Type.register(:inet, OID::Inet, adapter: :postgresql) + ActiveRecord::Type.register(:interval, OID::Interval, adapter: :postgresql) ActiveRecord::Type.register(:jsonb, OID::Jsonb, adapter: :postgresql) ActiveRecord::Type.register(:money, OID::Money, adapter: :postgresql) ActiveRecord::Type.register(:point, OID::Point, adapter: :postgresql) diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index b8f4c54cfa..75e2693763 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -527,6 +527,7 @@ module ActiveRecord @columns_hash.each do |name, column| type = connection.lookup_cast_type_from_column(column) type = _convert_type_from_options(type) + warn_if_deprecated_type(column) define_attribute( name, type, @@ -586,6 +587,32 @@ module ActiveRecord type end end + + def warn_if_deprecated_type(column) + return if attributes_to_define_after_schema_loads.key?(column.name) + return unless column.respond_to?(:oid) + + if column.array? + array_arguments = ", array: true" + else + array_arguments = "" + end + + if column.sql_type.start_with?("interval") + precision_arguments = column.precision.presence && ", precision: #{column.precision}" + ActiveSupport::Deprecation.warn(<<~WARNING) + The behavior of the `:interval` type will be changing in Rails 6.2 + to return an `ActiveSupport::Duration` object. If you'd like to keep + the old behavior, you can add this line to #{self.name} model: + + attribute :#{column.name}, :string#{precision_arguments}#{array_arguments} + + If you'd like the new behavior today, you can add this line: + + attribute :#{column.name}, :interval#{precision_arguments}#{array_arguments} + WARNING + end + end end end end diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb index 116a523726..2cfa35760d 100644 --- a/activerecord/test/cases/adapters/postgresql/datatype_test.rb +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -4,6 +4,9 @@ require "cases/helper" require "support/ddl_helper" class PostgresqlTime < ActiveRecord::Base + # Declare attributes to get rid from deprecation warnings on ActiveRecord 6.1 + attribute :time_interval, :string + attribute :scaled_time_interval, :interval end class PostgresqlOid < ActiveRecord::Base @@ -39,21 +42,14 @@ class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase end def test_time_values - assert_equal "-1 years -2 days", @first_time.time_interval - assert_equal "-21 days", @first_time.scaled_time_interval + assert_equal "P-1Y-2D", @first_time.time_interval + assert_equal (-21.day), @first_time.scaled_time_interval end def test_oid_values assert_equal 1234, @first_oid.obj_id end - def test_update_time - @first_time.time_interval = "2 years 3 minutes" - assert @first_time.save - assert @first_time.reload - assert_equal "2 years 00:03:00", @first_time.time_interval - end - def test_update_oid new_value = 2147483648 @first_oid.obj_id = new_value diff --git a/activerecord/test/cases/adapters/postgresql/interval_test.rb b/activerecord/test/cases/adapters/postgresql/interval_test.rb new file mode 100644 index 0000000000..3aed677bfd --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/interval_test.rb @@ -0,0 +1,102 @@ +# encoding: utf-8 +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlIntervalTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + class IntervalDataType < ActiveRecord::Base + attribute :maximum_term, :interval + attribute :minimum_term, :interval, precision: 3 + attribute :default_term, :interval + attribute :all_terms, :interval, array: true + attribute :legacy_term, :string + end + + class DeprecatedIntervalDataType < ActiveRecord::Base; end + + def setup + @connection = ActiveRecord::Base.connection + begin + @connection.transaction do + @connection.create_table("interval_data_types") do |t| + t.interval "maximum_term" + t.interval "minimum_term", precision: 3 + t.interval "default_term", default: "P3Y" + t.interval "all_terms", array: true + t.interval "legacy_term" + end + @connection.create_table("deprecated_interval_data_types") do |t| + t.interval "duration" + end + end + end + @column_max = IntervalDataType.columns_hash["maximum_term"] + @column_min = IntervalDataType.columns_hash["minimum_term"] + assert(@column_max.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLColumn)) + assert(@column_min.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLColumn)) + assert_nil @column_max.precision + assert_equal 3, @column_min.precision + end + + teardown do + @connection.execute "DROP TABLE IF EXISTS interval_data_types" + @connection.execute "DROP TABLE IF EXISTS deprecated_interval_data_types" + end + + def test_column + assert_equal :interval, @column_max.type + assert_equal :interval, @column_min.type + assert_equal "interval", @column_max.sql_type + assert_equal "interval(3)", @column_min.sql_type + end + + def test_interval_type + IntervalDataType.create!( + maximum_term: 6.year + 5.month + 4.days + 3.hours + 2.minutes + 1.seconds, + minimum_term: 1.year + 2.month + 3.days + 4.hours + 5.minutes + (6.234567).seconds, + all_terms: [1.month, 1.year, 1.hour], + legacy_term: "33 years", + ) + i = IntervalDataType.last! + assert_equal "P6Y5M4DT3H2M1S", i.maximum_term.iso8601 + assert_equal "P1Y2M3DT4H5M6.235S", i.minimum_term.iso8601 + assert_equal "P3Y", i.default_term.iso8601 + assert_equal %w[ P1M P1Y PT1H ], i.all_terms.map(&:iso8601) + assert_equal "P33Y", i.legacy_term + end + + def test_interval_type_cast_from_invalid_string + i = IntervalDataType.create!(maximum_term: "1 year 2 minutes") + i.reload + assert_nil i.maximum_term + end + + def test_interval_type_cast_from_numeric + i = IntervalDataType.create!(minimum_term: 36000) + i.reload + assert_equal "PT10H", i.minimum_term.iso8601 + end + + def test_interval_type_cast_string_and_numeric_from_user + i = IntervalDataType.new(maximum_term: "P1YT2M", minimum_term: "PT10H", legacy_term: "P1DT1H") + assert i.maximum_term.is_a?(ActiveSupport::Duration) + assert i.legacy_term.is_a?(String) + assert_equal "P1YT2M", i.maximum_term.iso8601 + assert_equal "PT10H", i.minimum_term.iso8601 + assert_equal "P1DT1H", i.legacy_term + end + + def test_deprecated_legacy_type + assert_deprecated do + DeprecatedIntervalDataType.new + end + end + + def test_schema_dump_with_default_value + output = dump_table_schema "interval_data_types" + assert_match %r{t\.interval "default_term", default: "P3Y"}, output + end +end diff --git a/guides/source/active_record_postgresql.md b/guides/source/active_record_postgresql.md index ece990ed50..6df8d311ad 100644 --- a/guides/source/active_record_postgresql.md +++ b/guides/source/active_record_postgresql.md @@ -404,6 +404,29 @@ macbook.address All geometric types, with the exception of `points` are mapped to normal text. A point is casted to an array containing `x` and `y` coordinates. +### Interval + +* [type definition](http://www.postgresql.org/docs/current/static/datatype-datetime.html#DATATYPE-INTERVAL-INPUT) +* [functions and operators](http://www.postgresql.org/docs/current/static/functions-datetime.html) + +This type is mapped to [`ActiveSupport::Duration`](http://api.rubyonrails.org/classes/ActiveSupport/Duration.html) objects. + +```ruby +# db/migrate/20200120000000_create_events.rb +create_table :events do |t| + t.interval 'duration' +end + +# app/models/event.rb +class Event < ApplicationRecord +end + +# Usage +Event.create(duration: 2.days) + +event = Event.first +event.duration # => 2 days +``` UUID Primary Keys -----------------