File: date_arithmetic.rb

package info (click to toggle)
ruby-sequel 5.63.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 10,408 kB
  • sloc: ruby: 113,747; makefile: 3
file content (254 lines) | stat: -rw-r--r-- 9,951 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# frozen-string-literal: true
#
# The date_arithmetic extension adds the ability to perform database-independent
# addition/substraction of intervals to/from dates and timestamps.
#
# First, you need to load the extension into the database:
#
#   DB.extension :date_arithmetic
#
# Then you can use the Sequel.date_add and Sequel.date_sub methods
# to return Sequel expressions (this example shows the only supported
# keys for the second argument):
#
#   add = Sequel.date_add(:date_column, years: 1, months: 2, weeks: 2, days: 1)
#   sub = Sequel.date_sub(:date_column, hours: 1, minutes: 2, seconds: 3)
#
# In addition to specifying the interval as a hash, there is also
# support for specifying the interval as an ActiveSupport::Duration
# object:
#
#   require 'active_support/all'
#   add = Sequel.date_add(:date_column, 1.years + 2.months + 3.days)
#   sub = Sequel.date_sub(:date_column, 1.hours + 2.minutes + 3.seconds)
#
# By default, values are casted to the generic timestamp type for the
# database.  You can override the cast type using the :cast option:
#
#   add = Sequel.date_add(:date_column, {years: 1, months: 2, days: 3}, cast: :timestamptz)
#
# These expressions can be used in your datasets, or anywhere else that
# Sequel expressions are allowed:
#
#   DB[:table].select(add.as(:d)).where(sub > Sequel::CURRENT_TIMESTAMP)
#
# On most databases, the values you provide for years/months/days/etc. must
# be numeric values and not arbitrary SQL expressions.  However, on PostgreSQL
# 9.4+, use of arbitrary SQL expressions is supported.
#
# Related module: Sequel::SQL::DateAdd

#
module Sequel
  module SQL
    module Builders
      # Return a DateAdd expression, adding an interval to the date/timestamp expr.
      # Options:
      # :cast :: Cast to the specified type instead of the default if casting
      def date_add(expr, interval, opts=OPTS)
        DateAdd.new(expr, interval, opts)
      end

      # Return a DateAdd expression, adding the negative of the interval to
      # the date/timestamp expr.
      # Options:
      # :cast :: Cast to the specified type instead of the default if casting
      def date_sub(expr, interval, opts=OPTS)
        if defined?(ActiveSupport::Duration) && interval.is_a?(ActiveSupport::Duration)
          interval = interval.parts
        end
        parts = {}
        interval.each do |k,v|
          case v
          when nil
            # ignore
          when Numeric
            parts[k] = -v
          else
            parts[k] = Sequel::SQL::NumericExpression.new(:*, v, -1)
          end
        end
        DateAdd.new(expr, parts, opts)
      end
    end

    # The DateAdd class represents the addition of an interval to a
    # date/timestamp expression.
    class DateAdd < GenericExpression
      # These methods are added to datasets using the date_arithmetic
      # extension, for the purposes of correctly literalizing DateAdd
      # expressions for the appropriate database type.
      module DatasetMethods
        DURATION_UNITS = [:years, :months, :days, :hours, :minutes, :seconds].freeze
        DEF_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| s.to_s.freeze}).freeze
        POSTGRES_DURATION_UNITS = DURATION_UNITS.zip([:years, :months, :days, :hours, :mins, :secs].map{|s| s.to_s.freeze}).freeze
        MYSQL_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| Sequel.lit(s.to_s.upcase[0...-1]).freeze}).freeze
        MSSQL_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| Sequel.lit(s.to_s[0...-1]).freeze}).freeze
        H2_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| s.to_s[0...-1].freeze}).freeze
        DERBY_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| Sequel.lit("SQL_TSI_#{s.to_s.upcase[0...-1]}").freeze}).freeze
        ACCESS_DURATION_UNITS = DURATION_UNITS.zip(%w'yyyy m d h n s'.map(&:freeze)).freeze
        DB2_DURATION_UNITS = DURATION_UNITS.zip(DURATION_UNITS.map{|s| Sequel.lit(s.to_s).freeze}).freeze

        # Append the SQL fragment for the DateAdd expression to the SQL query.
        def date_add_sql_append(sql, da)
          if defined?(super)
            return super
          end

          h = da.interval
          expr = da.expr
          cast_type = da.cast_type || Time

          cast = case db_type = db.database_type
          when :postgres
            casted = Sequel.cast(expr, cast_type)

            if db.server_version >= 90400
              placeholder = []
              vals = []
              each_valid_interval_unit(h, POSTGRES_DURATION_UNITS) do |value, sql_unit|
                placeholder << "#{', ' unless placeholder.empty?}#{sql_unit} := "
                vals << value
              end
              interval = Sequel.function(:make_interval, Sequel.lit(placeholder, *vals)) unless vals.empty?
            else
              parts = String.new
              each_valid_interval_unit(h, DEF_DURATION_UNITS) do |value, sql_unit|
                parts << "#{value} #{sql_unit} "
              end
              interval = Sequel.cast(parts, :interval) unless parts.empty?
            end

            if interval
              return complex_expression_sql_append(sql, :+, [casted, interval])
            else
              return literal_append(sql, casted)
            end
          when :sqlite
            args = [expr]
            each_valid_interval_unit(h, DEF_DURATION_UNITS) do |value, sql_unit|
              args << "#{value} #{sql_unit}"
            end
            return function_sql_append(sql, Sequel.function(:datetime, *args))
          when :mysql, :hsqldb
            if db_type == :hsqldb
              # HSQLDB requires 2.2.9+ for the DATE_ADD function
              expr = Sequel.cast(expr, cast_type)
            end
            each_valid_interval_unit(h, MYSQL_DURATION_UNITS) do |value, sql_unit|
              expr = Sequel.function(:DATE_ADD, expr, Sequel.lit(["INTERVAL ", " "], value, sql_unit))
            end
          when :mssql, :h2, :access, :sqlanywhere
            units = case db_type
            when :h2
              H2_DURATION_UNITS
            when :access
              ACCESS_DURATION_UNITS
            else
              MSSQL_DURATION_UNITS
            end
            each_valid_interval_unit(h, units) do |value, sql_unit|
              expr = Sequel.function(:DATEADD, sql_unit, value, expr)
            end
          when :derby
            if expr.is_a?(Date) && !expr.is_a?(DateTime)
              # Work around for https://issues.apache.org/jira/browse/DERBY-896
              expr = Sequel.cast_string(expr) + ' 00:00:00'
            end
            each_valid_interval_unit(h, DERBY_DURATION_UNITS) do |value, sql_unit|
              expr = Sequel.lit(["{fn timestampadd(#{sql_unit}, ", ", timestamp(", "))}"], value, expr)
            end
          when :oracle
            each_valid_interval_unit(h, MYSQL_DURATION_UNITS) do |value, sql_unit|
              expr = Sequel.+(expr, Sequel.lit(["INTERVAL ", " "], value.to_s, sql_unit))
            end
          when :db2
            expr = Sequel.cast(expr, cast_type)
            each_valid_interval_unit(h, DB2_DURATION_UNITS) do |value, sql_unit|
              expr = Sequel.+(expr, Sequel.lit(["", " "], value, sql_unit))
            end
            false
          else
            raise Error, "date arithmetic is not implemented on #{db.database_type}"
          end

          if cast
            expr = Sequel.cast(expr, cast_type)
          end

          literal_append(sql, expr)
        end

        private

        # Yield the value in the interval for each of the units
        # present in the interval, along with the SQL fragment
        # representing the unit name.  Returns false if any
        # values were yielded, true otherwise
        def each_valid_interval_unit(interval, units)
          cast = true
          units.each do |unit, sql_unit|
            if (value = interval[unit]) && value != 0
              cast = false
              yield value, sql_unit
            end
          end
          cast
        end
      end

      # The expression that the interval is being added to.
      attr_reader :expr

      # The interval added to the expression, as a hash with
      # symbol keys.
      attr_reader :interval

      # The type to cast the expression to.  nil if not overridden, in which cast
      # the generic timestamp type for the database will be used.
      attr_reader :cast_type

      # Supports two types of intervals:
      # Hash :: Used directly, but values cannot be plain strings.
      # ActiveSupport::Duration :: Converted to a hash using the interval's parts.
      def initialize(expr, interval, opts=OPTS)
        @expr = expr

        h = Hash.new(0)
        interval = interval.parts unless interval.is_a?(Hash)
        interval.each do |unit, value|
          # skip nil values
          next unless value

          # Convert weeks to days, as ActiveSupport::Duration can use weeks,
          # but the database-specific literalizers only support days.
          if unit == :weeks
            unit = :days
            value *= 7
          end

          unless DatasetMethods::DURATION_UNITS.include?(unit)
            raise Sequel::Error, "Invalid key used in DateAdd interval hash: #{unit.inspect}"
          end

          # Attempt to prevent SQL injection by users who pass untrusted strings
          # as interval values. It doesn't make sense to support literal strings,
          # due to the numeric adding below.
          if value.is_a?(String)
            raise Sequel::InvalidValue, "cannot provide String value as interval part: #{value.inspect}"
          end

          h[unit] += value
        end

        @interval = Hash[h].freeze
        @cast_type = opts[:cast] if opts[:cast]
        freeze
      end

      to_s_method :date_add_sql
    end
  end

  Dataset.register_extension(:date_arithmetic, SQL::DateAdd::DatasetMethods)
end