diff options
Diffstat (limited to 'activesupport')
-rw-r--r-- | activesupport/CHANGELOG.md | 16 | ||||
-rw-r--r-- | activesupport/lib/active_support/duration.rb | 20 | ||||
-rw-r--r-- | activesupport/lib/active_support/duration/iso8601_parser.rb | 122 | ||||
-rw-r--r-- | activesupport/lib/active_support/duration/iso8601_serializer.rb | 51 | ||||
-rw-r--r-- | activesupport/test/core_ext/duration_test.rb | 85 |
5 files changed, 294 insertions, 0 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index bcf3b10208..16b0a06d44 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,19 @@ +* `ActiveSupport::Duration` supports ISO8601 formatting and parsing. + + ActiveSupport::Duration.parse('P3Y6M4DT12H30M5S') + # => 3 years, 6 months, 4 days, 12 hours, 30 minutes, and 5 seconds + + (3.years + 3.days).iso8601 + # => "P3Y3D" + + Inspired by Arnau Siches' [ISO8601 gem](https://github.com/arnau/ISO8601/) + and rewritten by Andrey Novikov with suggestions from Andrew White. Test + data from the ISO8601 gem redistributed under MIT license. + + (Will be used to support the PostgreSQL interval data type.) + + *Andrey Novikov*, *Arnau Siches*, *Andrew White* + * `Cache#fetch(key, force: true)` forces a cache miss, so it must be called with a block to provide a new value to cache. Fetching with `force: true` but without a block now raises ArgumentError. diff --git a/activesupport/lib/active_support/duration.rb b/activesupport/lib/active_support/duration.rb index f67a42bcb1..3bde541009 100644 --- a/activesupport/lib/active_support/duration.rb +++ b/activesupport/lib/active_support/duration.rb @@ -9,6 +9,9 @@ module ActiveSupport class Duration attr_accessor :value, :parts + autoload :ISO8601Parser, 'active_support/duration/iso8601_parser' + autoload :ISO8601Serializer, 'active_support/duration/iso8601_serializer' + def initialize(value, parts) #:nodoc: @value, @parts = value, parts end @@ -130,6 +133,23 @@ module ActiveSupport @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 diff --git a/activesupport/lib/active_support/duration/iso8601_parser.rb b/activesupport/lib/active_support/duration/iso8601_parser.rb new file mode 100644 index 0000000000..07af58ad99 --- /dev/null +++ b/activesupport/lib/active_support/duration/iso8601_parser.rb @@ -0,0 +1,122 @@ +require 'strscan' + +module ActiveSupport + class Duration + # Parses a string formatted according to ISO 8601 Duration into the hash. + # + # See {ISO 8601}[http://en.wikipedia.org/wiki/ISO_8601#Durations] for more information. + # + # This parser allows negative parts to be present in pattern. + class ISO8601Parser # :nodoc: + class ParsingError < ::ArgumentError; end + + PERIOD_OR_COMMA = /\.|,/ + PERIOD = '.'.freeze + COMMA = ','.freeze + + SIGN_MARKER = /\A\-|\+|/ + DATE_MARKER = /P/ + TIME_MARKER = /T/ + DATE_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(Y|M|D|W)/ + TIME_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(H|M|S)/ + + DATE_TO_PART = { 'Y' => :years, 'M' => :months, 'W' => :weeks, 'D' => :days } + TIME_TO_PART = { 'H' => :hours, 'M' => :minutes, 'S' => :seconds } + + DATE_COMPONENTS = [:years, :months, :days] + TIME_COMPONENTS = [:hours, :minutes, :seconds] + + attr_reader :parts, :scanner + attr_accessor :mode, :sign + + def initialize(string) + @scanner = StringScanner.new(string) + @parts = {} + @mode = :start + @sign = 1 + end + + def parse! + while !finished? + case mode + when :start + if scan(SIGN_MARKER) + self.sign = (scanner.matched == '-') ? -1 : 1 + self.mode = :sign + else + raise_parsing_error + end + + when :sign + if scan(DATE_MARKER) + self.mode = :date + else + raise_parsing_error + end + + when :date + if scan(TIME_MARKER) + self.mode = :time + elsif scan(DATE_COMPONENT) + parts[DATE_TO_PART[scanner[2]]] = number * sign + else + raise_parsing_error + end + + when :time + if scan(TIME_COMPONENT) + parts[TIME_TO_PART[scanner[2]]] = number * sign + else + raise_parsing_error + end + + end + end + + validate! + parts + end + + private + + def finished? + scanner.eos? + end + + # Parses number which can be a float with either comma or period. + def number + scanner[1] =~ PERIOD_OR_COMMA ? scanner[1].tr(COMMA, PERIOD).to_f : scanner[1].to_i + end + + def scan(pattern) + scanner.scan(pattern) + end + + def raise_parsing_error(reason = nil) + raise ParsingError, "Invalid ISO 8601 duration: #{scanner.string.inspect} #{reason}".strip + end + + # Checks for various semantic errors as stated in ISO 8601 standard. + def validate! + raise_parsing_error('is empty duration') if parts.empty? + + # Mixing any of Y, M, D with W is invalid. + if parts.key?(:weeks) && (parts.keys & DATE_COMPONENTS).any? + raise_parsing_error('mixing weeks with other date parts not allowed') + end + + # Specifying an empty T part is invalid. + if mode == :time && (parts.keys & TIME_COMPONENTS).empty? + raise_parsing_error('time part marker is present but time part is empty') + end + + fractions = parts.values.reject(&:zero?).select { |a| (a % 1) != 0 } + unless fractions.empty? || (fractions.size == 1 && fractions.last == @parts.values.reject(&:zero?).last) + raise_parsing_error '(only last part can be fractional)' + end + + return true + end + end + end +end diff --git a/activesupport/lib/active_support/duration/iso8601_serializer.rb b/activesupport/lib/active_support/duration/iso8601_serializer.rb new file mode 100644 index 0000000000..05c6a083a9 --- /dev/null +++ b/activesupport/lib/active_support/duration/iso8601_serializer.rb @@ -0,0 +1,51 @@ +require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/hash/transform_values' + +module ActiveSupport + class Duration + # Serializes duration to string according to ISO 8601 Duration format. + class ISO8601Serializer + def initialize(duration, precision: nil) + @duration = duration + @precision = precision + end + + # Builds and returns output string. + def serialize + output = 'P' + parts, sign = normalize + output << "#{parts[:years]}Y" if parts.key?(:years) + output << "#{parts[:months]}M" if parts.key?(:months) + output << "#{parts[:weeks]}W" if parts.key?(:weeks) + output << "#{parts[:days]}D" if parts.key?(:days) + time = '' + time << "#{parts[:hours]}H" if parts.key?(:hours) + time << "#{parts[:minutes]}M" if parts.key?(:minutes) + if parts.key?(:seconds) + time << "#{sprintf(@precision ? "%0.0#{@precision}f" : '%g', parts[:seconds])}S" + end + output << "T#{time}" if time.present? + "#{sign}#{output}" + end + + private + + # Return pair of duration's parts and whole duration sign. + # Parts are summarized (as they can become repetitive due to addition, etc). + # Zero parts are removed as not significant. + # If all parts are negative it will negate all of them and return minus as a sign. + def normalize + parts = @duration.parts.each_with_object(Hash.new(0)) do |(k,v),p| + p[k] += v unless v.zero? + end + # If all parts are negative - let's make a negative duration + sign = '' + if parts.values.all? { |v| v < 0 } + sign = '-' + parts.transform_values!(&:-@) + end + [parts, sign] + end + end + end +end diff --git a/activesupport/test/core_ext/duration_test.rb b/activesupport/test/core_ext/duration_test.rb index a6a43048db..bef660fe12 100644 --- a/activesupport/test/core_ext/duration_test.rb +++ b/activesupport/test/core_ext/duration_test.rb @@ -223,4 +223,89 @@ class DurationTest < ActiveSupport::TestCase assert_equal(1, (1.minute <=> 1.second)) assert_equal(1, (61 <=> 1.minute)) end + + # ISO8601 string examples are taken from ISO8601 gem at https://github.com/arnau/ISO8601/blob/b93d466840/spec/iso8601/duration_spec.rb + # published under the conditions of MIT license at https://github.com/arnau/ISO8601/blob/b93d466840/LICENSE + # + # Copyright (c) 2012-2014 Arnau Siches + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining + # a copy of this software and associated documentation files (the + # "Software"), to deal in the Software without restriction, including + # without limitation the rights to use, copy, modify, merge, publish, + # distribute, sublicense, and/or sell copies of the Software, and to + # permit persons to whom the Software is furnished to do so, subject to + # the following conditions: + # + # The above copyright notice and this permission notice shall be + # included in all copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + def test_iso8601_parsing_wrong_patterns_with_raise + invalid_patterns = ['', 'P', 'PT', 'P1YT', 'T', 'PW', 'P1Y1W', '~P1Y', '.P1Y', 'P1.5Y0.5M', 'P1.5Y1M', 'P1.5MT10.5S'] + invalid_patterns.each do |pattern| + assert_raise ActiveSupport::Duration::ISO8601Parser::ParsingError, pattern.inspect do + ActiveSupport::Duration.parse(pattern) + end + end + end + + def test_iso8601_output + expectations = [ + ['P1Y', 1.year ], + ['P1W', 1.week ], + ['P1Y1M', 1.year + 1.month ], + ['P1Y1M1D', 1.year + 1.month + 1.day ], + ['-P1Y1D', -1.year - 1.day ], + ['P1Y-1DT-1S', 1.year - 1.day - 1.second ], # Parts with different signs are exists in PostgreSQL interval datatype. + ['PT1S', 1.second ], + ['PT1.4S', (1.4).seconds ], + ['P1Y1M1DT1H', 1.year + 1.month + 1.day + 1.hour], + ] + expectations.each do |expected_output, duration| + assert_equal expected_output, duration.iso8601, expected_output.inspect + end + end + + def test_iso8601_output_precision + expectations = [ + [nil, 'P1Y1MT5.55S', 1.year + 1.month + (5.55).seconds ], + [0, 'P1Y1MT6S', 1.year + 1.month + (5.55).seconds ], + [1, 'P1Y1MT5.5S', 1.year + 1.month + (5.55).seconds ], + [2, 'P1Y1MT5.55S', 1.year + 1.month + (5.55).seconds ], + [3, 'P1Y1MT5.550S', 1.year + 1.month + (5.55).seconds ], + [nil, 'PT1S', 1.second ], + [2, 'PT1.00S', 1.second ], + [nil, 'PT1.4S', (1.4).seconds ], + [0, 'PT1S', (1.4).seconds ], + [1, 'PT1.4S', (1.4).seconds ], + [5, 'PT1.40000S', (1.4).seconds ], + ] + expectations.each do |precision, expected_output, duration| + assert_equal expected_output, duration.iso8601(precision: precision), expected_output.inspect + end + end + + def test_iso8601_output_and_reparsing + patterns = %w[ + P1Y P0.5Y P0,5Y P1Y1M P1Y0.5M P1Y0,5M P1Y1M1D P1Y1M0.5D P1Y1M0,5D P1Y1M1DT1H P1Y1M1DT0.5H P1Y1M1DT0,5H P1W +P1Y -P1Y + P1Y1M1DT1H1M P1Y1M1DT1H0.5M P1Y1M1DT1H0,5M P1Y1M1DT1H1M1S P1Y1M1DT1H1M1.0S P1Y1M1DT1H1M1,0S P-1Y-2M3DT-4H-5M-6S + ] + # That could be weird, but if we parse P1Y1M0.5D and output it to ISO 8601, we'll get P1Y1MT12.0H. + # So we check that initially parsed and reparsed duration added to time will result in the same time. + time = Time.current + patterns.each do |pattern| + duration = ActiveSupport::Duration.parse(pattern) + assert_equal time+duration, time+ActiveSupport::Duration.parse(duration.iso8601), pattern.inspect + end + end end |