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
This commit is contained in:
Andrey Novikov 2020-01-20 19:26:41 +03:00 committed by Jeremy Daer
parent 5cfd58bbfb
commit e5a5cc4835
9 changed files with 255 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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