ruby-rdf/rdf

View on GitHub
lib/rdf/model/literal/temporal.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module RDF; class Literal
  ##
  # Shared methods and class ancestry for date, time, and dateTime literal classes.
  #
  # @since 3.1
  class Temporal < Literal
    # Matches either -10:00 or -P1H0M forms
    ZONE_GRAMMAR  = %r(\A
       (?:(?<si>[+-])(?<hr>\d{2}):(?:(?<mi>\d{2}))?)
      |(?:(?<si>-)?PT(?<hr>\d{1,2})H(?:(?<mi>\d{1,2})M)?)
    \z)x.freeze

    YEARFRAG = %r(-?(?:(?:[1-9]\d{3,})|(?:0\d{3})))
    MONTHFRAG = %r((?:(?:0[1-9])|(?:1[0-2])))
    DAYFRAG = %r((?:(?:0[1-9])|(?:[12]\d)|(?:3[01])))
    HOURFRAG = %r((?:[01]\d)|(?:2[0-3]))
    MINUTEFRAG = %r([0-5]\d)
    SECONDFRAG = %r([0-5]\d(?:\.\d+)?)
    EODFRAG = %r(24:00:00(?:\.0+)?)
    TZFRAG = %r((?:[\+\-]\d{2}:\d{2})|UTC|GMT|Z)

    ##
    # Compares this literal to `other` for sorting purposes.
    #
    # @param  [Object] other
    # @return [Integer] `-1`, `0`, or `1`
    def <=>(other)
      # If lexically invalid, use regular literal testing
      return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)
      return super unless other.is_a?(self.class)
      @object <=> other.object
    end

    ##
    # Returns `true` if this literal is equal to `other`.
    #
    # @param  [Object] other
    # @return [Boolean] `true` or `false`
    # @since  0.3.0
    def ==(other)
      # If lexically invalid, use regular literal testing
      return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)

      case other
      when self.class
        self.object == other.object
      when Literal::Temporal
        false
      else
        super
      end
    end

    ##
    # Converts this literal into its canonical lexical representation.
    # with date and time normalized to UTC.
    #
    # @return [RDF::Literal] `self`
    # @see    http://www.w3.org/TR/xmlschema11-2/#dateTime
    def canonicalize!
      if self.valid? && @zone && @zone != '+00:00'
        adjust_to_timezone!
      else
        @string = nil
      end
      self
    end

    ##
    # Returns the timezone part of arg as a simple literal. Returns the empty string if there is no timezone.
    #
    # @return [RDF::Literal]
    def tz
      RDF::Literal(@zone == "+00:00" ? 'Z' : @zone)
    end

    ##
    # Does the literal representation include a timezone? Note that this is only possible if initialized using a string, or `:lexical` option.
    #
    # @return [Boolean]
    # @since 1.1.6
    def timezone?
      # Can only know there's a timezone from the string represntation
      md = to_s.match(self.class.const_get(:GRAMMAR))
      md && !!md[2]
    end
    alias_method :tz?, :timezone?
    alias_method :has_tz?, :timezone?
    alias_method :has_timezone?, :timezone?

    ##
    # Returns the timezone part of arg as an xsd:dayTimeDuration, or `nil`
    # if lexical form of literal does not include a timezone.
    #
    # From [fn:timezone-from-date](https://www.w3.org/TR/xpath-functions/#func-timezone-from-date).
    #
    # @return [RDF::Literal]
    # @see https://www.w3.org/TR/xpath-functions/#func-timezone-from-date
    def timezone
      if @zone
        md = @zone.match(ZONE_GRAMMAR)
        si, hr, mi = md[:si], md[:hr].to_i, md[:mi].to_i
        si = nil unless si == "-"
        res = "#{si}PT#{hr}H#{"#{mi}M" if mi > 0}"
        RDF::Literal(res, datatype: RDF::URI("http://www.w3.org/2001/XMLSchema#dayTimeDuration"))
      end
    end

    ##
    # Returns `true` if the value adheres to the defined grammar of the
    # datatype.
    #
    # Special case for date and dateTime, for which '0000' is not a valid year
    #
    # @return [Boolean]
    # @since  0.2.1
    def valid?
      super && object && value !~ %r(\A0000)
    end

    ##
    # Does the literal representation include millisectonds?
    #
    # @return [Boolean]
    # @since 1.1.6
    def milliseconds?
      object.strftime("%L").to_i > 0
    end
    alias_method :has_milliseconds?, :milliseconds?
    alias_method :has_ms?, :milliseconds?
    alias_method :ms?, :milliseconds?

    ##
    # Returns the `timezone` of the literal. If the
    ##
    # Returns the value as a string.
    #
    # @return [String]
    def to_s
      @string ||= (@object.strftime(self.class.const_get(:FORMAT)).sub('.000', '') + self.tz)
    end

    ##
    # Adjust the timezone.
    #
    # From [fn:adjust-dateTime-to-timezone](https://www.w3.org/TR/xpath-functions/#func-adjust-dateTime-to-timezone)
    #
    # @overload adjust_to_timezone!
    #   Adjusts the timezone to UTC.
    #
    #   @return [Temporal] `self`
    #   @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
    # @overload adjust_to_timezone!(zone)
    #   If `zone` is nil, then the timzeone component is removed.
    #
    #   Otherwise, the timezone is set based on the difference between the current timezone offset (if any) and `zone`.
    #
    #   @param [DayTimeDuration, String] zone (nil) In the form of {ZONE_GRAMMAR}.
    #   @return [Temporal] `self`
    #   @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
    # @see https://www.w3.org/TR/xpath-functions/#func-adjust-dateTime-to-timezone
    def adjust_to_timezone!(*args)
      zone = args.empty? ? '+00:00' : args.first
      if zone.to_s.empty?
        # Remove timezone component
        @object = self.class.new(@object.strftime(self.class.const_get(:FORMAT))).object
        @zone = nil
      else
        md = zone.to_s.match(ZONE_GRAMMAR)
        raise ArgumentError,
              "expected #{zone.inspect} to be a xsd:dayTimeDuration or +/-HH:MM" unless md

        # Adjust to zone
        si, hr, mi = md[:si], md[:hr], md[:mi]
        si ||= '+'
        offset = hr.to_i * 60 + mi.to_i
        raise ArgumentError,
              "Zone adjustment of #{zone} out of range" if
              md.nil? || offset > 14*60

        new_zone = "%s%.2d:%.2d" % [si, hr.to_i, mi.to_i]
        dt = @zone.nil? ? @object : @object.new_offset(new_zone)
        @object = self.class.new(dt.strftime(self.class.const_get(:FORMAT) + new_zone)).object
        @zone = new_zone
      end
      @string = nil
      self
    end

    ##
    # Functional version of `#adjust_to_timezone!`.
    #
    # @overload adjust_to_timezone
    #   @param (see #adjust_to_timezone!)
    #   @return [DateTime]
    #   @raise (see #adjust_to_timezone!)
    # @overload adjust_to_timezone(zone) (see #adjust_to_timezone!)
    #   @return [DateTime]
    #   @raise (see #adjust_to_timezone!)
    def adjust_to_timezone(*args)
      self.dup.adjust_to_timezone!(*args)
    end

    ##
    # Add a Duration to a Temporal.
    #
    # For YearMonthDuration, turns duration into months and adds to internal DateTime object.
    #
    # For DayTimeDuration, turns duration into rational days, and adds to internal DateTime object.
    #
    # @note This depends on the parameter responding to `#to_i` or `#to_r`, which for Duration types, is implemented in the rdf-xsd gem.
    #
    # @param [YearMonthDuration, DayTimeDuration] other
    # @return [Temporal]
    # @see https://www.w3.org/TR/xpath-functions/#func-add-yearMonthDuration-to-dateTime
    # @see https://www.w3.org/TR/xpath-functions/#func-add-dayTimeDuration-to-dateTime
    def +(other)
      new_dt = case other
      when YearMonthDuration
        @object >> other.to_i
      when DayTimeDuration
        @object + other.to_r
      else
        return super
      end

      dt = new_dt.strftime(self.class.const_get(:FORMAT)) + tz
      self.class.new(dt)
    rescue NoMethodError => e
      raise "Consider including the rdf-xsd class for method implementaions: #{e.message}"
    end

    ##
    # Subtract times or durations from a temporal.
    #
    # @overload +(other)
    #   For YearMonthDuration, turns duration into months and subtracts from internal DateTime object resulting in a new {Temporal} object.
    #
    #   For DayTimeDuration, turns duration into rational days, and subtracts from internal DateTime object resulting in a new {Temporal} object.
    #
    #   For Temporal, subtracts the two moments resulting in a `xsd:dayTimeDuration`.
    #
    #   @param [YearMonthDuration, DayTimeDurationm, Temporal] other
    #   @return [Temporal, DayTimeDuration]
    #   @note This depends on the parameter responding to `#to_i` or `#to_r`, which for Duration types, is implemented in the rdf-xsd gem.
    #   @see https://www.w3.org/TR/xpath-functions/#func-subtract-yearMonthDuration-from-dateTime
    #   @see https://www.w3.org/TR/xpath-functions/#func-subtract-dayTimeDuration-from-dateTime
    #   @see https://www.w3.org/TR/xpath-functions/#func-subtract-dateTimes
    def -(other)
      new_dt = case other
      when YearMonthDuration
        @object << other.to_i
      when DayTimeDuration
        @object - other.to_r
      when Temporal
        @object - other.object
      else
        return super
      end

      if new_dt.is_a?(Rational)
        RDF::Literal(new_dt, datatype: RDF::XSD.dayTimeDuration)
      else
        dt = new_dt.strftime(self.class.const_get(:FORMAT)) + tz
        self.class.new(dt)
      end
    rescue NoMethodError => e
      raise "Consider including the rdf-xsd class for method implementaions: #{e.message}"
    end

    # Years
    #
    # From the XQuery function [fn:year-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-year-from-dateTime).
    #
    # @return [Integer]
    # @see https://www.w3.org/TR/xpath-functions/#func-year-from-dateTime
    def year; Integer.new(object.year); end

    # Months
    #
    # From the XQuery function [fn:month-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-month-from-dateTime).
    #
    # @return [Integer]
    # @see https://www.w3.org/TR/xpath-functions/#func-month-from-dateTime
    def month; Integer.new(object.month); end

    # Days
    #
    # From the XQuery function [fn:day-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-day-from-dateTime).
    #
    # @return [Integer]
    # @see https://www.w3.org/TR/xpath-functions/#func-day-from-dateTime
    def day; Integer.new(object.day); end

    # Hours
    #
    # From the XQuery function [fn:hours-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-hours-from-dateTime).
    #
    # @return [Integer]
    # @see https://www.w3.org/TR/xpath-functions/#func-hours-from-dateTime
    def hours; Integer.new(object.hour); end

    # Minutes
    #
    # From the XQuery function [fn:minutes-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-minutes-from-dateTime).
    #
    # @return [Integer]
    # @see https://www.w3.org/TR/xpath-functions/#func-minutes-from-dateTime
    def minutes; Integer.new(object.min); end

    # Seconds
    #
    # From the XQuery function [fn:seconds-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-seconds-from-dateTime).
    #
    # @return [Decimal]
    # @see https://www.w3.org/TR/xpath-functions/#func-seconds-from-dateTime
    def seconds; Decimal.new(object.strftime("%S.%L")); end
  end # Temporal
end; end # RDF::Literal