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.
- checksums.yaml +7 -0
- data/groupdate-6.8.0/CHANGELOG.md +329 -0
- data/groupdate-6.8.0/CONTRIBUTING.md +75 -0
- data/groupdate-6.8.0/LICENSE.txt +22 -0
- data/groupdate-6.8.0/README.md +284 -0
- data/groupdate-6.8.0/lib/groupdate/active_record.rb +6 -0
- data/groupdate-6.8.0/lib/groupdate/adapters/base_adapter.rb +46 -0
- data/groupdate-6.8.0/lib/groupdate/adapters/mysql_adapter.rb +63 -0
- data/groupdate-6.8.0/lib/groupdate/adapters/postgresql_adapter.rb +46 -0
- data/groupdate-6.8.0/lib/groupdate/adapters/sqlite_adapter.rb +86 -0
- data/groupdate-6.8.0/lib/groupdate/enumerable.rb +25 -0
- data/groupdate-6.8.0/lib/groupdate/magic.rb +269 -0
- data/groupdate-6.8.0/lib/groupdate/query_methods.rb +18 -0
- data/groupdate-6.8.0/lib/groupdate/relation.rb +19 -0
- data/groupdate-6.8.0/lib/groupdate/series_builder.rb +309 -0
- data/groupdate-6.8.0/lib/groupdate/version.rb +3 -0
- data/groupdate-6.8.0/lib/groupdate.rb +54 -0
- data/micro-lite-rb.gemspec +12 -0
- metadata +58 -0
|
@@ -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
|