diff options
Diffstat (limited to 'activesupport/lib/active_support/duration.rb')
-rw-r--r-- | activesupport/lib/active_support/duration.rb | 335 |
1 files changed, 289 insertions, 46 deletions
diff --git a/activesupport/lib/active_support/duration.rb b/activesupport/lib/active_support/duration.rb index 47d09f4f5a..fe1058762b 100644 --- a/activesupport/lib/active_support/duration.rb +++ b/activesupport/lib/active_support/duration.rb @@ -1,5 +1,10 @@ -require 'active_support/core_ext/array/conversions' -require 'active_support/core_ext/object/acts_like' +# frozen_string_literal: true + +require "active_support/core_ext/array/conversions" +require "active_support/core_ext/module/delegation" +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/string/filters" +require "active_support/deprecation" module ActiveSupport # Provides accurate date and time measurements using Date#advance and @@ -7,22 +12,237 @@ module ActiveSupport # # 1.month.ago # equivalent to Time.now.advance(months: -1) class Duration + class Scalar < Numeric #:nodoc: + attr_reader :value + delegate :to_i, :to_f, :to_s, to: :value + + def initialize(value) + @value = value + end + + def coerce(other) + [Scalar.new(other), self] + end + + def -@ + Scalar.new(-value) + end + + def <=>(other) + if Scalar === other || Duration === other + value <=> other.value + elsif Numeric === other + value <=> other + else + nil + end + end + + def +(other) + if Duration === other + seconds = value + other.parts[:seconds] + new_parts = other.parts.merge(seconds: seconds) + new_value = value + other.value + + Duration.new(new_value, new_parts) + else + calculate(:+, other) + end + end + + def -(other) + if Duration === other + seconds = value - other.parts[:seconds] + new_parts = other.parts.map { |part, other_value| [part, -other_value] }.to_h + new_parts = new_parts.merge(seconds: seconds) + new_value = value - other.value + + Duration.new(new_value, new_parts) + else + calculate(:-, other) + end + end + + def *(other) + if Duration === other + new_parts = other.parts.map { |part, other_value| [part, value * other_value] }.to_h + new_value = value * other.value + + Duration.new(new_value, new_parts) + else + calculate(:*, other) + end + end + + def /(other) + if Duration === other + value / other.value + else + calculate(:/, other) + end + end + + def %(other) + if Duration === other + Duration.build(value % other.value) + else + calculate(:%, other) + end + end + + private + def calculate(op, other) + if Scalar === other + Scalar.new(value.public_send(op, other.value)) + elsif Numeric === other + Scalar.new(value.public_send(op, other)) + else + raise_type_error(other) + end + end + + def raise_type_error(other) + raise TypeError, "no implicit conversion of #{other.class} into #{self.class}" + end + end + + SECONDS_PER_MINUTE = 60 + SECONDS_PER_HOUR = 3600 + SECONDS_PER_DAY = 86400 + SECONDS_PER_WEEK = 604800 + SECONDS_PER_MONTH = 2629746 # 1/12 of a gregorian year + SECONDS_PER_YEAR = 31556952 # length of a gregorian year (365.2425 days) + + PARTS_IN_SECONDS = { + seconds: 1, + minutes: SECONDS_PER_MINUTE, + hours: SECONDS_PER_HOUR, + days: SECONDS_PER_DAY, + weeks: SECONDS_PER_WEEK, + months: SECONDS_PER_MONTH, + years: SECONDS_PER_YEAR + }.freeze + + PARTS = [:years, :months, :weeks, :days, :hours, :minutes, :seconds].freeze + attr_accessor :value, :parts - autoload :ISO8601Parser, 'active_support/duration/iso8601_parser' - autoload :ISO8601Serializer, 'active_support/duration/iso8601_serializer' + autoload :ISO8601Parser, "active_support/duration/iso8601_parser" + autoload :ISO8601Serializer, "active_support/duration/iso8601_serializer" + + class << self + # Creates a new Duration from string formatted according to ISO 8601 Duration. + # + # See {ISO 8601}[https://en.wikipedia.org/wiki/ISO_8601#Durations] for more information. + # This method allows negative parts to be present in pattern. + # If invalid string is provided, it will raise +ActiveSupport::Duration::ISO8601Parser::ParsingError+. + def parse(iso8601duration) + parts = ISO8601Parser.new(iso8601duration).parse! + new(calculate_total_seconds(parts), parts) + end + + def ===(other) #:nodoc: + other.is_a?(Duration) + rescue ::NoMethodError + false + end + + def seconds(value) #:nodoc: + new(value, [[:seconds, value]]) + end + + def minutes(value) #:nodoc: + new(value * SECONDS_PER_MINUTE, [[:minutes, value]]) + end + + def hours(value) #:nodoc: + new(value * SECONDS_PER_HOUR, [[:hours, value]]) + end + + def days(value) #:nodoc: + new(value * SECONDS_PER_DAY, [[:days, value]]) + end + + def weeks(value) #:nodoc: + new(value * SECONDS_PER_WEEK, [[:weeks, value]]) + end + + def months(value) #:nodoc: + new(value * SECONDS_PER_MONTH, [[:months, value]]) + end + + def years(value) #:nodoc: + new(value * SECONDS_PER_YEAR, [[:years, value]]) + end + + # Creates a new Duration from a seconds value that is converted + # to the individual parts: + # + # ActiveSupport::Duration.build(31556952).parts # => {:years=>1} + # ActiveSupport::Duration.build(2716146).parts # => {:months=>1, :days=>1} + # + def build(value) + parts = {} + remainder = value.to_f + + PARTS.each do |part| + unless part == :seconds + part_in_seconds = PARTS_IN_SECONDS[part] + parts[part] = remainder.div(part_in_seconds) + remainder = (remainder % part_in_seconds).round(9) + end + end + + parts[:seconds] = remainder + + new(value, parts) + end + + private + + def calculate_total_seconds(parts) + parts.inject(0) do |total, (part, value)| + total + value * PARTS_IN_SECONDS[part] + end + end + end def initialize(value, parts) #:nodoc: - @value, @parts = value, parts + @value, @parts = value, parts.to_h + @parts.default = 0 + @parts.reject! { |k, v| v.zero? } + end + + def coerce(other) #:nodoc: + if Scalar === other + [other, self] + else + [Scalar.new(other), self] + end + end + + # Compares one Duration with another or a Numeric to this Duration. + # Numeric values are treated as seconds. + def <=>(other) + if Duration === other + value <=> other.value + elsif Numeric === other + value <=> other + end end # Adds another Duration or a Numeric to this Duration. Numeric values # are treated as seconds. def +(other) if Duration === other - Duration.new(value + other.value, @parts + other.parts) + parts = @parts.dup + other.parts.each do |(key, value)| + parts[key] += value + end + Duration.new(value + other.value, parts) else - Duration.new(value + other, @parts + [[:seconds, other]]) + seconds = @parts[:seconds] + other + Duration.new(value + other, @parts.merge(seconds: seconds)) end end @@ -32,8 +252,44 @@ module ActiveSupport self + (-other) end + # Multiplies this Duration by a Numeric and returns a new Duration. + def *(other) + if Scalar === other || Duration === other + Duration.new(value * other.value, parts.map { |type, number| [type, number * other.value] }) + elsif Numeric === other + Duration.new(value * other, parts.map { |type, number| [type, number * other] }) + else + raise_type_error(other) + end + end + + # Divides this Duration by a Numeric and returns a new Duration. + def /(other) + if Scalar === other + Duration.new(value / other.value, parts.map { |type, number| [type, number / other.value] }) + elsif Duration === other + value / other.value + elsif Numeric === other + Duration.new(value / other, parts.map { |type, number| [type, number / other] }) + else + raise_type_error(other) + end + end + + # Returns the modulo of this Duration by another Duration or Numeric. + # Numeric values are treated as seconds. + def %(other) + if Duration === other || Scalar === other + Duration.build(value % other.value) + elsif Numeric === other + Duration.build(value % other) + else + raise_type_error(other) + end + end + def -@ #:nodoc: - Duration.new(-value, parts.map { |type,number| [type, -number] }) + Duration.new(-value, parts.map { |type, number| [type, -number] }) end def is_a?(klass) #:nodoc: @@ -70,14 +326,14 @@ module ActiveSupport # 1.day.to_i # => 86400 # # Note that this conversion makes some assumptions about the - # duration of some periods, e.g. months are always 30 days - # and years are 365.25 days: + # duration of some periods, e.g. months are always 1/12 of year + # and years are 365.2425 days: # - # # equivalent to 30.days.to_i - # 1.month.to_i # => 2592000 + # # equivalent to (1.year / 12).to_i + # 1.month.to_i # => 2629746 # - # # equivalent to 365.25.days.to_i - # 1.year.to_i # => 31557600 + # # equivalent to 365.2425.days.to_i + # 1.year.to_i # => 31556952 # # In such cases, Ruby's core # Date[http://ruby-doc.org/stdlib/libdoc/date/rdoc/Date.html] and @@ -97,18 +353,13 @@ module ActiveSupport @value.hash end - def self.===(other) #:nodoc: - other.is_a?(Duration) - rescue ::NoMethodError - false - end - # Calculates a new Time or Date that is as far in the future # as this Duration represents. def since(time = ::Time.current) sum(1, time) end alias :from_now :since + alias :after :since # Calculates a new Time or Date that is as far in the past # as this Duration represents. @@ -116,12 +367,15 @@ module ActiveSupport sum(-1, time) end alias :until :ago + alias :before :ago def inspect #:nodoc: + return "0 seconds" if parts.empty? + parts. - reduce(::Hash.new(0)) { |h,(l,r)| h[l] += r; h }. - sort_by {|unit, _ | [:years, :months, :weeks, :days, :hours, :minutes, :seconds].index(unit)}. - map {|unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}"}. + reduce(::Hash.new(0)) { |h, (l, r)| h[l] += r; h }. + sort_by { |unit, _ | PARTS.index(unit) }. + map { |unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}" }. to_sentence(locale: ::I18n.default_locale) end @@ -129,33 +383,16 @@ module ActiveSupport to_i end - def respond_to_missing?(method, include_private=false) #:nodoc: - @value.respond_to?(method, include_private) - end - - # Creates a new Duration from string formatted according to ISO 8601 Duration. - # - # See {ISO 8601}[http://en.wikipedia.org/wiki/ISO_8601#Durations] for more information. - # This method allows negative parts to be present in pattern. - # If invalid string is provided, it will raise +ActiveSupport::Duration::ISO8601Parser::ParsingError+. - def self.parse(iso8601duration) - parts = ISO8601Parser.new(iso8601duration).parse! - time = ::Time.current - new(time.advance(parts) - time, parts) - end - # Build ISO 8601 Duration string for this duration. # The +precision+ parameter can be used to limit seconds' precision of duration. def iso8601(precision: nil) ISO8601Serializer.new(self, precision: precision).serialize end - delegate :<=>, to: :value - - protected + private - def sum(sign, time = ::Time.current) #:nodoc: - parts.inject(time) do |t,(type,number)| + def sum(sign, time = ::Time.current) + parts.inject(time) do |t, (type, number)| if t.acts_like?(:time) || t.acts_like?(:date) if type == :seconds t.since(sign * number) @@ -172,10 +409,16 @@ module ActiveSupport end end - private + def respond_to_missing?(method, _) + value.respond_to?(method) + end + + def method_missing(method, *args, &block) + value.public_send(method, *args, &block) + end - def method_missing(method, *args, &block) #:nodoc: - value.send(method, *args, &block) + def raise_type_error(other) + raise TypeError, "no implicit conversion of #{other.class} into #{self.class}" end end end |