micro-lite-rb 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class BaseAdapter
4
+ attr_reader :period, :column, :day_start, :week_start, :n_seconds
5
+
6
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:, n_seconds:, adapter_name: nil)
7
+ @relation = relation
8
+ @column = column
9
+ @period = period
10
+ @time_zone = time_zone
11
+ @time_range = time_range
12
+ @week_start = week_start
13
+ @day_start = day_start
14
+ @n_seconds = n_seconds
15
+ @adapter_name = adapter_name
16
+
17
+ if ActiveRecord.default_timezone == :local
18
+ raise Groupdate::Error, "ActiveRecord.default_timezone must be :utc to use Groupdate"
19
+ end
20
+ end
21
+
22
+ def generate
23
+ @relation.group(group_clause).where(*where_clause)
24
+ end
25
+
26
+ private
27
+
28
+ def where_clause
29
+ if @time_range.is_a?(Range)
30
+ if @time_range.end
31
+ op = @time_range.exclude_end? ? "<" : "<="
32
+ if @time_range.begin
33
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.begin, @time_range.end]
34
+ else
35
+ ["#{column} #{op} ?", @time_range.end]
36
+ end
37
+ else
38
+ ["#{column} >= ?", @time_range.begin]
39
+ end
40
+ else
41
+ ["#{column} IS NOT NULL"]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class MySQLAdapter < BaseAdapter
4
+ def group_clause
5
+ time_zone = @time_zone.tzinfo.name
6
+ day_start_column = "CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL ? second"
7
+
8
+ query =
9
+ case period
10
+ when :minute_of_hour
11
+ ["MINUTE(#{day_start_column})", time_zone, day_start]
12
+ when :hour_of_day
13
+ ["HOUR(#{day_start_column})", time_zone, day_start]
14
+ when :day_of_week
15
+ ["DAYOFWEEK(#{day_start_column}) - 1", time_zone, day_start]
16
+ when :day_of_month
17
+ ["DAYOFMONTH(#{day_start_column})", time_zone, day_start]
18
+ when :day_of_year
19
+ ["DAYOFYEAR(#{day_start_column})", time_zone, day_start]
20
+ when :month_of_year
21
+ ["MONTH(#{day_start_column})", time_zone, day_start]
22
+ when :week
23
+ ["CAST(DATE_FORMAT(#{day_start_column} - INTERVAL ((? + DAYOFWEEK(#{day_start_column})) % 7) DAY, '%Y-%m-%d') AS DATE)", time_zone, day_start, 12 - week_start, time_zone, day_start]
24
+ when :quarter
25
+ ["CAST(CONCAT(YEAR(#{day_start_column}), '-', LPAD(1 + 3 * (QUARTER(#{day_start_column}) - 1), 2, '00'), '-01') AS DATE)", time_zone, day_start, time_zone, day_start]
26
+ when :day, :month, :year
27
+ format =
28
+ case period
29
+ when :day
30
+ "%Y-%m-%d"
31
+ when :month
32
+ "%Y-%m-01"
33
+ else # year
34
+ "%Y-01-01"
35
+ end
36
+
37
+ ["CAST(DATE_FORMAT(#{day_start_column}, ?) AS DATE)", time_zone, day_start, format]
38
+ when :custom
39
+ ["FROM_UNIXTIME((UNIX_TIMESTAMP(#{column}) DIV ?) * ?)", n_seconds, n_seconds]
40
+ else
41
+ format =
42
+ case period
43
+ when :second
44
+ "%Y-%m-%d %H:%i:%S"
45
+ when :minute
46
+ "%Y-%m-%d %H:%i:00"
47
+ else # hour
48
+ "%Y-%m-%d %H:00:00"
49
+ end
50
+
51
+ ["CONVERT_TZ(DATE_FORMAT(#{day_start_column}, ?) + INTERVAL ? second, ?, '+00:00')", time_zone, day_start, format, day_start, time_zone]
52
+ end
53
+
54
+ clean_group_clause(@relation.send(:sanitize_sql_array, query))
55
+ end
56
+
57
+ def clean_group_clause(clause)
58
+ # zero quoted in Active Record 7+
59
+ clause.gsub(/ (\-|\+) INTERVAL 0 second/, "").gsub(/ (\-|\+) INTERVAL '0' second/, "")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,46 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class PostgreSQLAdapter < BaseAdapter
4
+ def group_clause
5
+ time_zone = @time_zone.tzinfo.name
6
+ day_start_column = "#{column}::timestamptz AT TIME ZONE ? - INTERVAL ?"
7
+ day_start_interval = "#{day_start} second"
8
+
9
+ query =
10
+ case period
11
+ when :minute_of_hour
12
+ ["EXTRACT(MINUTE FROM #{day_start_column})::integer", time_zone, day_start_interval]
13
+ when :hour_of_day
14
+ ["EXTRACT(HOUR FROM #{day_start_column})::integer", time_zone, day_start_interval]
15
+ when :day_of_week
16
+ ["EXTRACT(DOW FROM #{day_start_column})::integer", time_zone, day_start_interval]
17
+ when :day_of_month
18
+ ["EXTRACT(DAY FROM #{day_start_column})::integer", time_zone, day_start_interval]
19
+ when :day_of_year
20
+ ["EXTRACT(DOY FROM #{day_start_column})::integer", time_zone, day_start_interval]
21
+ when :month_of_year
22
+ ["EXTRACT(MONTH FROM #{day_start_column})::integer", time_zone, day_start_interval]
23
+ when :week
24
+ ["(DATE_TRUNC('day', #{day_start_column} - INTERVAL '1 day' * ((? + EXTRACT(DOW FROM #{day_start_column})::integer) % 7)) + INTERVAL ?)::date", time_zone, day_start_interval, 13 - week_start, time_zone, day_start_interval, day_start_interval]
25
+ when :custom
26
+ if @adapter_name == "Redshift"
27
+ ["TIMESTAMP 'epoch' + (FLOOR(EXTRACT(EPOCH FROM #{column}::timestamp) / ?) * ?) * INTERVAL '1 second'", n_seconds, n_seconds]
28
+ else
29
+ ["TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM #{column}::timestamptz) / ?) * ?)", n_seconds, n_seconds]
30
+ end
31
+ when :day, :month, :quarter, :year
32
+ ["DATE_TRUNC(?, #{day_start_column})::date", period, time_zone, day_start_interval]
33
+ else
34
+ # day start is always 0 for seconds, minute, hour
35
+ ["DATE_TRUNC(?, #{day_start_column}) AT TIME ZONE ?", period, time_zone, day_start_interval, time_zone]
36
+ end
37
+
38
+ clean_group_clause(@relation.send(:sanitize_sql_array, query))
39
+ end
40
+
41
+ def clean_group_clause(clause)
42
+ clause.gsub(/ (\-|\+) INTERVAL '0 second'/, "")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,86 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class SQLiteAdapter < BaseAdapter
4
+ def group_clause
5
+ query =
6
+ if period == :week
7
+ ["strftime('%Y-%m-%d', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
8
+ elsif period == :custom
9
+ ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
10
+ else
11
+ format =
12
+ case period
13
+ when :minute_of_hour
14
+ "%M"
15
+ when :hour_of_day
16
+ "%H"
17
+ when :day_of_week
18
+ "%w"
19
+ when :day_of_month
20
+ "%d"
21
+ when :day_of_year
22
+ "%j"
23
+ when :month_of_year
24
+ "%m"
25
+ when :second
26
+ "%Y-%m-%d %H:%M:%S UTC"
27
+ when :minute
28
+ "%Y-%m-%d %H:%M:00 UTC"
29
+ when :hour
30
+ "%Y-%m-%d %H:00:00 UTC"
31
+ when :day
32
+ "%Y-%m-%d"
33
+ when :month
34
+ "%Y-%m-01"
35
+ when :quarter
36
+ nil
37
+ else # year
38
+ "%Y-01-01"
39
+ end
40
+
41
+ ["strftime(?, #{column})", format]
42
+ end
43
+
44
+ if period != :custom && (@time_zone != SeriesBuilder.utc || day_start != 0 || period == :quarter)
45
+ setup_function
46
+ week_start = period == :week ? Groupdate::Magic::DAYS[self.week_start].to_s : nil
47
+ query = ["groupdate_internal(?, #{column}, ?, ?, ?)", period, @time_zone.tzinfo.name, day_start, week_start]
48
+ end
49
+
50
+ @relation.send(:sanitize_sql_array, query)
51
+ end
52
+
53
+ private
54
+
55
+ def setup_function
56
+ @relation.connection_pool.with_connection do |connection|
57
+ raw_connection = connection.raw_connection
58
+ return if raw_connection.instance_variable_defined?(:@groupdate_function)
59
+
60
+ utc = SeriesBuilder.utc
61
+ date_periods = %i[day week month quarter year]
62
+
63
+ # note: this function is part of the internal API and may change between releases
64
+ # TODO improve performance
65
+ raw_connection.create_function("groupdate_internal", 4) do |func, period, value, time_zone, day_start, week_start|
66
+ if value.nil?
67
+ func.result = nil
68
+ else
69
+ period = period.to_sym
70
+ # cast_result handles week_start for day_of_week
71
+ week_start = :sunday if period == :day_of_week
72
+ result = SeriesBuilder.round_time(utc.parse(value), period, ActiveSupport::TimeZone[time_zone], day_start.to_i, week_start&.to_sym)
73
+ if date_periods.include?(period)
74
+ result = result.strftime("%Y-%m-%d")
75
+ elsif result.is_a?(Time)
76
+ result = result.in_time_zone(utc).strftime("%Y-%m-%d %H:%M:%S")
77
+ end
78
+ func.result = result
79
+ end
80
+ end
81
+ raw_connection.instance_variable_set(:@groupdate_function, true)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ module Enumerable
2
+ Groupdate::PERIODS.each do |period|
3
+ define_method :"group_by_#{period}" do |*args, **options, &block|
4
+ if block
5
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
+ Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
7
+ elsif respond_to?(:scoping)
8
+ scoping { klass.group_by_period(period, *args, **options, &block) }
9
+ else
10
+ raise ArgumentError, "no block given"
11
+ end
12
+ end
13
+ end
14
+
15
+ def group_by_period(period, *args, **options, &block)
16
+ if block || !respond_to?(:scoping)
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
+
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
21
+ else
22
+ scoping { klass.group_by_period(period, *args, **options, &block) }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,269 @@
1
+ require "i18n"
2
+
3
+ module Groupdate
4
+ class Magic
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
7
+ attr_accessor :period, :options, :group_index, :n_seconds
8
+
9
+ def initialize(period:, **options)
10
+ @period = period
11
+ @options = options
12
+
13
+ validate_keywords
14
+ validate_arguments
15
+
16
+ if options[:n]
17
+ raise ArgumentError, "n must be a positive integer" if !options[:n].is_a?(Integer) || options[:n] < 1
18
+ @period = :custom
19
+ @n_seconds = options[:n].to_i
20
+ @n_seconds *= 60 if period == :minute
21
+ end
22
+ end
23
+
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :series, :format, :locale, :range, :expand_range, :reverse]
26
+
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
29
+ end
30
+
31
+ if %i[day week month quarter year day_of_week hour_of_day day_of_month day_of_year month_of_year].include?(period)
32
+ known_keywords << :day_start
33
+ else
34
+ # prevent Groupdate.day_start from applying
35
+ @day_start = 0
36
+ end
37
+
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
40
+ end
41
+
42
+ unknown_keywords = options.keys - known_keywords
43
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
44
+ end
45
+
46
+ def validate_arguments
47
+ # TODO better messages
48
+ raise ArgumentError, "Unrecognized time zone" unless time_zone
49
+ raise ArgumentError, "Unrecognized :week_start option" unless week_start
50
+ raise ArgumentError, ":day_start must be between 0 and 24" if (day_start / 3600) < 0 || (day_start / 3600) >= 24
51
+ end
52
+
53
+ def time_zone
54
+ @time_zone ||= begin
55
+ time_zone = "Etc/UTC" if options[:time_zone] == false
56
+ time_zone ||= options[:time_zone] || Groupdate.time_zone || (Groupdate.time_zone == false && "Etc/UTC") || Time.zone || "Etc/UTC"
57
+ time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone]
58
+ end
59
+ end
60
+
61
+ def week_start
62
+ @week_start ||= begin
63
+ v = (options[:week_start] || Groupdate.week_start).to_sym
64
+ DAYS.index(v) || [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index(v)
65
+ end
66
+ end
67
+
68
+ def day_start
69
+ @day_start ||= ((options[:day_start] || Groupdate.day_start).to_f * 3600).round
70
+ end
71
+
72
+ def range
73
+ @range ||= begin
74
+ time_range = options[:range]
75
+
76
+ if time_range.is_a?(Range) && time_range.begin.nil? && time_range.end.nil?
77
+ nil
78
+ else
79
+ time_range
80
+ end
81
+ end
82
+ end
83
+
84
+ def series_builder
85
+ @series_builder ||=
86
+ SeriesBuilder.new(
87
+ **options,
88
+ period: period,
89
+ time_zone: time_zone,
90
+ range: range,
91
+ day_start: day_start,
92
+ week_start: week_start,
93
+ n_seconds: n_seconds
94
+ )
95
+ end
96
+
97
+ def time_range
98
+ series_builder.time_range
99
+ end
100
+
101
+ def self.validate_period(period, permit)
102
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
103
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
104
+ end
105
+
106
+ class Enumerable < Magic
107
+ def group_by(enum, &_block)
108
+ group = enum.group_by do |v|
109
+ v = yield(v)
110
+ raise ArgumentError, "Not a time" unless v.respond_to?(:to_time)
111
+ series_builder.round_time(v)
112
+ end
113
+ series_builder.generate(group, default_value: [], series_default: false)
114
+ end
115
+
116
+ def self.group_by(enum, period, options, &block)
117
+ Groupdate::Magic::Enumerable.new(period: period, **options).group_by(enum, &block)
118
+ end
119
+ end
120
+
121
+ class Relation < Magic
122
+ def initialize(**options)
123
+ super(**options.reject { |k, _| [:default_value, :carry_forward, :last, :current].include?(k) })
124
+ @options = options
125
+ end
126
+
127
+ def perform(relation, result, default_value:)
128
+ if defined?(ActiveRecord::Promise) && result.is_a?(ActiveRecord::Promise)
129
+ return result.then { |r| perform(relation, r, default_value: default_value) }
130
+ end
131
+
132
+ multiple_groups = relation.group_values.size > 1
133
+
134
+ check_nils(result, multiple_groups, relation)
135
+ result = cast_result(result, multiple_groups)
136
+
137
+ series_builder.generate(
138
+ result,
139
+ default_value: options.key?(:default_value) ? options[:default_value] : default_value,
140
+ multiple_groups: multiple_groups,
141
+ group_index: group_index
142
+ )
143
+ end
144
+
145
+ def cast_method
146
+ @cast_method ||= begin
147
+ case period
148
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
149
+ lambda { |k| k.to_i }
150
+ when :day_of_week
151
+ lambda { |k| (k.to_i - 1 - week_start) % 7 }
152
+ when :day, :week, :month, :quarter, :year
153
+ # TODO keep as date
154
+ if day_start != 0
155
+ day_start_hour = day_start / 3600
156
+ day_start_min = (day_start % 3600) / 60
157
+ day_start_sec = (day_start % 3600) % 60
158
+ lambda { |k| k.in_time_zone(time_zone).change(hour: day_start_hour, min: day_start_min, sec: day_start_sec) }
159
+ else
160
+ lambda { |k| k.in_time_zone(time_zone) }
161
+ end
162
+ else
163
+ utc = ActiveSupport::TimeZone["UTC"]
164
+ lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
165
+ end
166
+ end
167
+ end
168
+
169
+ def cast_result(result, multiple_groups)
170
+ new_result = {}
171
+ result.each do |k, v|
172
+ if multiple_groups
173
+ k[group_index] = cast_method.call(k[group_index])
174
+ else
175
+ k = cast_method.call(k)
176
+ end
177
+ new_result[k] = v
178
+ end
179
+ new_result
180
+ end
181
+
182
+ def time_zone_support?(relation)
183
+ relation.connection_pool.with_connection do |connection|
184
+ if connection.adapter_name.match?(/mysql|trilogy/i)
185
+ sql = relation.send(:sanitize_sql_array, ["SELECT CONVERT_TZ(NOW(), '+00:00', ?)", time_zone.tzinfo.name])
186
+ !connection.select_all(sql).to_a.first.values.first.nil?
187
+ else
188
+ true
189
+ end
190
+ end
191
+ end
192
+
193
+ def check_nils(result, multiple_groups, relation)
194
+ has_nils = multiple_groups ? (result.keys.first && result.keys.first[group_index].nil?) : result.key?(nil)
195
+ if has_nils
196
+ if time_zone_support?(relation)
197
+ raise Groupdate::Error, "Invalid query - be sure to use a date or time column"
198
+ else
199
+ raise Groupdate::Error, "Database missing time zone support for #{time_zone.tzinfo.name} - see https://github.com/ankane/groupdate#for-mysql"
200
+ end
201
+ end
202
+ end
203
+
204
+ def self.generate_relation(relation, field:, **options)
205
+ magic = Groupdate::Magic::Relation.new(**options)
206
+
207
+ adapter_name = relation.connection_pool.with_connection { |c| c.adapter_name }
208
+ adapter = Groupdate.adapters[adapter_name]
209
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
210
+
211
+ # very important
212
+ column = validate_column(field)
213
+ column = resolve_column(relation, column)
214
+
215
+ # generate ActiveRecord relation
216
+ relation =
217
+ adapter.new(
218
+ relation,
219
+ column: column,
220
+ period: magic.period,
221
+ time_zone: magic.time_zone,
222
+ time_range: magic.time_range,
223
+ week_start: magic.week_start,
224
+ day_start: magic.day_start,
225
+ n_seconds: magic.n_seconds,
226
+ adapter_name: adapter_name
227
+ ).generate
228
+
229
+ # add Groupdate info
230
+ magic.group_index = relation.group_values.size - 1
231
+ (relation.groupdate_values ||= []) << magic
232
+
233
+ relation
234
+ end
235
+
236
+ class << self
237
+ # basic version of Active Record disallow_raw_sql!
238
+ # symbol = column (safe), Arel node = SQL (safe), other = untrusted
239
+ # matches table.column and column
240
+ def validate_column(column)
241
+ unless column.is_a?(Symbol) || column.is_a?(Arel::Nodes::SqlLiteral)
242
+ column = column.to_s
243
+ unless /\A\w+(\.\w+)?\z/i.match?(column)
244
+ raise ActiveRecord::UnknownAttributeReference, "Query method called with non-attribute argument(s): #{column.inspect}. Use Arel.sql() for known-safe values."
245
+ end
246
+ end
247
+ column
248
+ end
249
+
250
+ # resolves eagerly
251
+ # need to convert both where_clause (easy)
252
+ # and group_clause (not easy) if want to avoid this
253
+ def resolve_column(relation, column)
254
+ node = relation.send(:relation).send(:arel_columns, [column]).first
255
+ node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
256
+ relation.connection_pool.with_connection { |c| c.visitor.accept(node, Arel::Collectors::SQLString.new).value }
257
+ end
258
+ end
259
+
260
+ # allow any options to keep flexible for future
261
+ def self.process_result(relation, result, **options)
262
+ relation.groupdate_values.reverse_each do |gv|
263
+ result = gv.perform(relation, result, default_value: options[:default_value])
264
+ end
265
+ result
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,18 @@
1
+ module Groupdate
2
+ module QueryMethods
3
+ Groupdate::PERIODS.each do |period|
4
+ define_method :"group_by_#{period}" do |field, **options|
5
+ Groupdate::Magic::Relation.generate_relation(self,
6
+ period: period,
7
+ field: field,
8
+ **options
9
+ )
10
+ end
11
+ end
12
+
13
+ def group_by_period(period, field, permit: nil, **options)
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ require "active_support/concern"
2
+
3
+ module Groupdate
4
+ module Relation
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ attr_accessor :groupdate_values
9
+ end
10
+
11
+ def calculate(*args, &block)
12
+ # prevent calculate from being called twice
13
+ return super if has_include?(args[1])
14
+
15
+ default_value = [:count, :sum].include?(args[0]) ? 0 : nil
16
+ Groupdate.process_result(self, super, default_value: default_value)
17
+ end
18
+ end
19
+ end