mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
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:
parent
5cfd58bbfb
commit
e5a5cc4835
9 changed files with 255 additions and 14 deletions
|
@ -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`.
|
* Allow associations supporting the `dependent:` key to take `dependent: :destroy_async`.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
|
|
|
@ -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/enum"
|
||||||
require "active_record/connection_adapters/postgresql/oid/hstore"
|
require "active_record/connection_adapters/postgresql/oid/hstore"
|
||||||
require "active_record/connection_adapters/postgresql/oid/inet"
|
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/jsonb"
|
||||||
require "active_record/connection_adapters/postgresql/oid/macaddr"
|
require "active_record/connection_adapters/postgresql/oid/macaddr"
|
||||||
require "active_record/connection_adapters/postgresql/oid/money"
|
require "active_record/connection_adapters/postgresql/oid/money"
|
||||||
|
|
|
@ -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
|
|
@ -566,6 +566,12 @@ module ActiveRecord
|
||||||
when 5..8; "bigint"
|
when 5..8; "bigint"
|
||||||
else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
|
else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
|
||||||
end
|
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
|
else
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
|
@ -561,11 +561,6 @@ module ActiveRecord
|
||||||
m.register_type "polygon", OID::SpecializedString.new(:polygon)
|
m.register_type "polygon", OID::SpecializedString.new(:polygon)
|
||||||
m.register_type "circle", OID::SpecializedString.new(:circle)
|
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, "time", Type::Time
|
||||||
register_class_with_precision m, "timestamp", OID::DateTime
|
register_class_with_precision m, "timestamp", OID::DateTime
|
||||||
|
|
||||||
|
@ -589,6 +584,11 @@ module ActiveRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
m.register_type "interval" do |*args, sql_type|
|
||||||
|
precision = extract_precision(sql_type)
|
||||||
|
OID::Interval.new(precision: precision)
|
||||||
|
end
|
||||||
|
|
||||||
load_additional_types
|
load_additional_types
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -794,6 +794,9 @@ module ActiveRecord
|
||||||
end
|
end
|
||||||
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
|
# SET statements from :variables config hash
|
||||||
# https://www.postgresql.org/docs/current/static/sql-set.html
|
# https://www.postgresql.org/docs/current/static/sql-set.html
|
||||||
variables.map do |k, v|
|
variables.map do |k, v|
|
||||||
|
@ -962,6 +965,7 @@ module ActiveRecord
|
||||||
ActiveRecord::Type.register(:enum, OID::Enum, adapter: :postgresql)
|
ActiveRecord::Type.register(:enum, OID::Enum, adapter: :postgresql)
|
||||||
ActiveRecord::Type.register(:hstore, OID::Hstore, adapter: :postgresql)
|
ActiveRecord::Type.register(:hstore, OID::Hstore, adapter: :postgresql)
|
||||||
ActiveRecord::Type.register(:inet, OID::Inet, 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(:jsonb, OID::Jsonb, adapter: :postgresql)
|
||||||
ActiveRecord::Type.register(:money, OID::Money, adapter: :postgresql)
|
ActiveRecord::Type.register(:money, OID::Money, adapter: :postgresql)
|
||||||
ActiveRecord::Type.register(:point, OID::Point, adapter: :postgresql)
|
ActiveRecord::Type.register(:point, OID::Point, adapter: :postgresql)
|
||||||
|
|
|
@ -527,6 +527,7 @@ module ActiveRecord
|
||||||
@columns_hash.each do |name, column|
|
@columns_hash.each do |name, column|
|
||||||
type = connection.lookup_cast_type_from_column(column)
|
type = connection.lookup_cast_type_from_column(column)
|
||||||
type = _convert_type_from_options(type)
|
type = _convert_type_from_options(type)
|
||||||
|
warn_if_deprecated_type(column)
|
||||||
define_attribute(
|
define_attribute(
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
|
@ -586,6 +587,32 @@ module ActiveRecord
|
||||||
type
|
type
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,9 @@ require "cases/helper"
|
||||||
require "support/ddl_helper"
|
require "support/ddl_helper"
|
||||||
|
|
||||||
class PostgresqlTime < ActiveRecord::Base
|
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
|
end
|
||||||
|
|
||||||
class PostgresqlOid < ActiveRecord::Base
|
class PostgresqlOid < ActiveRecord::Base
|
||||||
|
@ -39,21 +42,14 @@ class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_time_values
|
def test_time_values
|
||||||
assert_equal "-1 years -2 days", @first_time.time_interval
|
assert_equal "P-1Y-2D", @first_time.time_interval
|
||||||
assert_equal "-21 days", @first_time.scaled_time_interval
|
assert_equal (-21.day), @first_time.scaled_time_interval
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_oid_values
|
def test_oid_values
|
||||||
assert_equal 1234, @first_oid.obj_id
|
assert_equal 1234, @first_oid.obj_id
|
||||||
end
|
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
|
def test_update_oid
|
||||||
new_value = 2147483648
|
new_value = 2147483648
|
||||||
@first_oid.obj_id = new_value
|
@first_oid.obj_id = new_value
|
||||||
|
|
102
activerecord/test/cases/adapters/postgresql/interval_test.rb
Normal file
102
activerecord/test/cases/adapters/postgresql/interval_test.rb
Normal 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
|
|
@ -404,6 +404,29 @@ macbook.address
|
||||||
All geometric types, with the exception of `points` are mapped to normal text.
|
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.
|
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
|
UUID Primary Keys
|
||||||
-----------------
|
-----------------
|
||||||
|
|
Loading…
Reference in a new issue