lib/duckdb/interval.rb



# frozen_string_literal: true

module DuckDB
  # Interval class represents DuckDB's interval type value.
  #
  # The usage is as follows:
  #
  #   require 'duckdb'
  #
  #   interval = DuckDB::Interval.new(interval_months: 14, interval_days: 3, interval_micros: 14706123456)
  #   # or
  #   # interval = DuckDB::Interval.mk_interval(year: 1, month: 2, day: 3, hour: 4, min: 5, sec: 6, usec: 123456)
  #   # or
  #   # interval = DuckDB::Interval.iso8601_parse('P1Y2M3DT4H5M6.123456S')
  #
  #   db = DuckDB::Database.open # database in memory
  #   con = db.connect
  #
  #   con.execute('CREATE TABLE users (value INTERVAL)')
  #
  #   require 'duckdb'
  #   db = DuckDB::Database.open
  #   con = db.connect
  #   con.query('CREATE TABLE intervals (interval_value INTERVAL)')
  #   appender = con.appender('intervals')
  #   appender
  #     .append_interval(interval)
  #     .end_row
  #     .flush
  class Interval
    # :stopdoc:
    ISO8601_REGEXP = Regexp.compile(
      '(?<negativ>-{0,1})P
      (?<year>-{0,1}\d+Y){0,1}
      (?<month>-{0,1}\d+M){0,1}
      (?<day>-{0,1}\d+D){0,1}
      T{0,1}
      (?<hour>-{0,1}\d+H){0,1}
      (?<min>-{0,1}\d+M){0,1}
      ((?<sec>-{0,1}\d+)\.{0,1}(?<usec>\d*)S){0,1}',
      Regexp::EXTENDED
    )
    private_constant :ISO8601_REGEXP
    # :startdoc:

    class << self
      # parses the ISO8601 format string and return the Interval object.
      #
      #   DuckDB::Interval.iso8601_parse('P1Y2M3DT4H5M6.123456S')
      #   => #<DuckDB::Interval:0x00007f9b9c0b3b60 @interval_months=14, @interval_days=3, @interval_micros=14706123456>
      def iso8601_parse(value)
        m = ISO8601_REGEXP.match(value)

        raise ArgumentError, "The argument `#{value}` can't be parse." if m.nil?

        year, month, day, hour, min, sec, usec = matched_to_i(m)

        mk_interval(year: year, month: month, day: day, hour: hour, min: min, sec: sec, usec: usec)
      end

      # creates the Interval object.
      #
      #   DuckDB::Interval.mk_interval(year: 1, month: 2, day: 3, hour: 4, min: 5, sec: 6, usec: 123456)
      #   => #<DuckDB::Interval:0x00007f9b9c0b3b60 @interval_months=14, @interval_days=3, @interval_micros=14706123456>
      def mk_interval(year: 0, month: 0, day: 0, hour: 0, min: 0, sec: 0, usec: 0)
        Interval.new(
          interval_months: (year * 12) + month,
          interval_days: day,
          interval_micros: (((hour * 3600) + (min * 60) + sec) * 1_000_000) + usec
        )
      end

      # Convert the value to the Interval object.
      # The value can be String or Interval object.
      # If the value is String, it is parsed as ISO8601 format.
      # If the value is Interval object, it is returned as is.
      # Otherwise, ArgumentError is raised.
      #
      #   DuckDB::Interval.to_interval('P1Y2M3DT4H5M6.123456S')
      #   => #<DuckDB::Interval:0x00007f9b9c0b3b60 @interval_months=14, @interval_days=3, @interval_micros=14706123456>
      #
      #   interval = DuckDB::Interval.to_interval('P1Y2M3DT4H5M6.123456S')
      #   DuckDB::Interval.to_interval(interval)
      #   => #<DuckDB::Interval:0x00007f9b9c0b3b60 @interval_months=14, @interval_days=3, @interval_micros=14706123456>
      def to_interval(value)
        case value
        when String
          iso8601_parse(value)
        when Interval
          value
        else
          raise ArgumentError, "The argument `#{value}` can't be parse."
        end
      end

      private

      def matched_to_i(matched) # :nodoc:
        sign = to_sign(matched)
        sec = to_sec(matched)
        usec = to_usec(matched)
        usec *= -1 if sec.negative?
        value = [
          to_year(matched), to_month(matched), to_day(matched), to_hour(matched), to_min(matched), sec, usec
        ]
        sign.positive? ? value : value.map { |v| v * sign }
      end

      def to_sign(matched) # :nodoc:
        matched[:negativ] == '-' ? -1 : 1
      end

      def to_year(matched) # :nodoc:
        matched[:year].to_i
      end

      def to_month(matched) # :nodoc:
        matched[:month].to_i
      end

      def to_day(matched) # :nodoc:
        matched[:day].to_i
      end

      def to_hour(matched) # :nodoc:
        matched[:hour].to_i
      end

      def to_min(matched) # :nodoc:
        matched[:min].to_i
      end

      def to_sec(matched) # :nodoc:
        matched[:sec].to_i
      end

      def to_usec(matched) # :nodoc:
        matched[:usec].to_s.ljust(6, '0')[0, 6].to_i
      end
    end

    attr_reader :interval_months, :interval_days, :interval_micros

    # creates the Interval object.
    # The arguments are the number of months, days, and microseconds.
    # The default value is 0.
    #
    #   DuckDB::Interval.new(interval_months: 1, interval_days: 2, interval_micros: 3)
    #   => #<DuckDB::Interval:0x00007f9b9c0b3b60 @interval_months=1, @interval_days=2, @interval_micros=3>
    def initialize(interval_months: 0, interval_days: 0, interval_micros: 0)
      @interval_months = interval_months
      @interval_days = interval_days
      @interval_micros = interval_micros
    end

    def ==(other)
      other.is_a?(Interval) &&
        @interval_months == other.interval_months &&
        @interval_days == other.interval_days &&
        @interval_micros == other.interval_micros
    end

    def eql?(other)
      self == other
    end
  end
end