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
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
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
|
||||
-----------------
|
||||
|
|
Loading…
Reference in New Issue