reports_kit 0.0.4 → 0.1.0
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 +4 -4
- data/Appraisals +7 -0
- data/README.md +21 -14
- data/docs/README.md +5 -4
- data/gemfiles/mysql.gemfile +7 -0
- data/gemfiles/mysql.gemfile.lock +154 -0
- data/gemfiles/postgresql.gemfile +7 -0
- data/gemfiles/postgresql.gemfile.lock +152 -0
- data/lib/reports_kit.rb +3 -0
- data/lib/reports_kit/reports/adapters/mysql.rb +15 -0
- data/lib/reports_kit/reports/adapters/postgresql.rb +15 -0
- data/lib/reports_kit/reports/data/chart_options.rb +23 -0
- data/lib/reports_kit/reports/data/one_dimension.rb +1 -1
- data/lib/reports_kit/reports/data/two_dimensions.rb +8 -4
- data/lib/reports_kit/reports/data/utils.rb +67 -32
- data/lib/reports_kit/reports/dimension.rb +53 -2
- data/lib/reports_kit/reports/filter_types/boolean.rb +18 -3
- data/lib/reports_kit/reports/filter_types/datetime.rb +17 -3
- data/lib/reports_kit/reports/generate_autocomplete_results.rb +10 -2
- data/lib/reports_kit/reports/inferrable_configuration.rb +2 -1
- data/lib/reports_kit/version.rb +1 -1
- data/reports_kit.gemspec +2 -1
- data/spec/fixtures/generate_inputs.yml +5 -0
- data/spec/fixtures/generate_outputs.yml +15 -0
- data/spec/reports_kit/reports/data/generate_spec.rb +80 -47
- data/spec/reports_kit/reports/dimension_spec.rb +17 -1
- data/spec/spec_helper.rb +18 -7
- data/spec/support/helpers.rb +9 -1
- data/spec/support/schema.rb +1 -0
- metadata +27 -6
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module ReportsKit
|
|
2
|
+
module Reports
|
|
3
|
+
module Adapters
|
|
4
|
+
class Postgresql
|
|
5
|
+
def self.truncate_to_day(column)
|
|
6
|
+
"#{column}::date"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.truncate_to_week(column)
|
|
10
|
+
"DATE_TRUNC('week', #{column}::timestamp)"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -44,6 +44,7 @@ module ReportsKit
|
|
|
44
44
|
usePointStyle: true
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
+
maintainAspectRatio: false,
|
|
47
48
|
tooltips: {
|
|
48
49
|
xPadding: 8,
|
|
49
50
|
yPadding: 7
|
|
@@ -73,6 +74,22 @@ module ReportsKit
|
|
|
73
74
|
private
|
|
74
75
|
|
|
75
76
|
def set_colors
|
|
77
|
+
if donut_or_pie_chart?
|
|
78
|
+
set_record_scoped_colors
|
|
79
|
+
else
|
|
80
|
+
set_dataset_scoped_colors
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_record_scoped_colors
|
|
85
|
+
self.data[:chart_data][:datasets] = self.data[:chart_data][:datasets].map do |dataset|
|
|
86
|
+
length = dataset[:data].length
|
|
87
|
+
dataset[:backgroundColor] = DEFAULT_COLORS * (length.to_f / DEFAULT_COLORS.length).ceil
|
|
88
|
+
dataset
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def set_dataset_scoped_colors
|
|
76
93
|
self.data[:chart_data][:datasets] = data[:chart_data][:datasets].map.with_index do |dataset, index|
|
|
77
94
|
color = DEFAULT_COLORS[index % DEFAULT_COLORS.length]
|
|
78
95
|
dataset[:backgroundColor] = color
|
|
@@ -83,6 +100,8 @@ module ReportsKit
|
|
|
83
100
|
|
|
84
101
|
def default_options
|
|
85
102
|
@default_options ||= begin
|
|
103
|
+
return {} if donut_or_pie_chart?
|
|
104
|
+
|
|
86
105
|
default_options = DEFAULT_OPTIONS.deep_dup
|
|
87
106
|
|
|
88
107
|
x_axis_label = options[:x_axis_label]
|
|
@@ -126,6 +145,10 @@ module ReportsKit
|
|
|
126
145
|
return if type.blank?
|
|
127
146
|
self.data[:type] = type
|
|
128
147
|
end
|
|
148
|
+
|
|
149
|
+
def donut_or_pie_chart?
|
|
150
|
+
type.in?(%w(donut pie))
|
|
151
|
+
end
|
|
129
152
|
end
|
|
130
153
|
end
|
|
131
154
|
end
|
|
@@ -32,7 +32,7 @@ module ReportsKit
|
|
|
32
32
|
relation = relation.order('2')
|
|
33
33
|
end
|
|
34
34
|
dimension_keys_values = relation.distinct.public_send(*measure.aggregate_function)
|
|
35
|
-
dimension_keys_values = Utils.
|
|
35
|
+
dimension_keys_values = Utils.populate_sparse_hash(dimension_keys_values, dimension: dimension)
|
|
36
36
|
dimension_keys_values.delete(nil)
|
|
37
37
|
dimension_keys_values.delete('')
|
|
38
38
|
dimension_keys_values
|
|
@@ -35,11 +35,12 @@ module ReportsKit
|
|
|
35
35
|
else
|
|
36
36
|
relation = relation.order('2')
|
|
37
37
|
end
|
|
38
|
-
dimension_keys_values = relation.
|
|
38
|
+
dimension_keys_values = relation.distinct.public_send(*measure.aggregate_function)
|
|
39
39
|
|
|
40
40
|
if dimension.should_be_sorted_by_count?
|
|
41
41
|
dimension_keys_values = sort_dimension_keys_values_by_count(dimension_keys_values)
|
|
42
42
|
end
|
|
43
|
+
dimension_keys_values = Utils.populate_sparse_hash(dimension_keys_values, dimension: dimension)
|
|
43
44
|
Hash[dimension_keys_values]
|
|
44
45
|
end
|
|
45
46
|
end
|
|
@@ -48,6 +49,8 @@ module ReportsKit
|
|
|
48
49
|
@primary_keys_secondary_keys_values ||= begin
|
|
49
50
|
primary_keys_secondary_keys_values = {}
|
|
50
51
|
dimension_keys_values.each do |(primary_key, secondary_key), value|
|
|
52
|
+
primary_key = primary_key.to_date if primary_key.is_a?(Time)
|
|
53
|
+
secondary_key = secondary_key.to_date if secondary_key.is_a?(Time)
|
|
51
54
|
primary_keys_secondary_keys_values[primary_key] ||= {}
|
|
52
55
|
primary_keys_secondary_keys_values[primary_key][secondary_key] = value
|
|
53
56
|
end
|
|
@@ -91,16 +94,17 @@ module ReportsKit
|
|
|
91
94
|
end
|
|
92
95
|
secondary_keys_values = secondary_keys_values.sort_by { |_, values| values.sum }.reverse
|
|
93
96
|
secondary_keys_values.map do |secondary_key, values|
|
|
97
|
+
next if secondary_key.blank?
|
|
94
98
|
{
|
|
95
99
|
label: Utils.dimension_key_to_label(secondary_key, second_dimension, second_dimension_ids_dimension_instances),
|
|
96
100
|
data: values
|
|
97
101
|
}
|
|
98
|
-
end
|
|
102
|
+
end.compact
|
|
99
103
|
end
|
|
100
104
|
|
|
101
105
|
def primary_keys
|
|
102
106
|
@primary_keys ||= begin
|
|
103
|
-
keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:first).uniq)
|
|
107
|
+
keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:first).uniq, dimension: dimension)
|
|
104
108
|
if dimension.should_be_sorted_by_count?
|
|
105
109
|
limit = dimension.dimension_instances_limit
|
|
106
110
|
keys = keys.first(limit) if limit
|
|
@@ -111,7 +115,7 @@ module ReportsKit
|
|
|
111
115
|
|
|
112
116
|
def secondary_keys
|
|
113
117
|
@secondary_keys ||= begin
|
|
114
|
-
keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:last).uniq)
|
|
118
|
+
keys = Utils.populate_sparse_keys(dimension_keys_values.keys.map(&:last).uniq, dimension: second_dimension)
|
|
115
119
|
limit = second_dimension.dimension_instances_limit
|
|
116
120
|
keys = keys.first(limit) if limit
|
|
117
121
|
keys
|
|
@@ -6,50 +6,85 @@ module ReportsKit
|
|
|
6
6
|
time.strftime('%b %-d, \'%y')
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def self.
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
time = first_key
|
|
19
|
-
full_dimension_instances_values = []
|
|
20
|
-
if use_first_value_key
|
|
21
|
-
first_value_key = dimension_keys_values.first.last.keys.first
|
|
22
|
-
blank_value = { first_value_key => 0 }
|
|
9
|
+
def self.populate_sparse_hash(hash, dimension:)
|
|
10
|
+
return hash if hash.blank?
|
|
11
|
+
keys = hash.keys
|
|
12
|
+
is_nested = false
|
|
13
|
+
if keys.first.is_a?(Array)
|
|
14
|
+
is_nested = true
|
|
15
|
+
keys_values = arrays_values_to_nested_hash(hash)
|
|
16
|
+
keys = keys_values.keys
|
|
23
17
|
else
|
|
24
|
-
|
|
18
|
+
keys_values = hash
|
|
25
19
|
end
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
|
|
21
|
+
first_key = dimension.first_key || keys.first
|
|
22
|
+
return hash unless first_key.is_a?(Time) || first_key.is_a?(Date)
|
|
23
|
+
keys_values = keys_values.map do |key, value|
|
|
24
|
+
key = key.to_date if key.is_a?(Time)
|
|
25
|
+
[key, value]
|
|
26
|
+
end.to_h
|
|
27
|
+
|
|
28
|
+
keys = populate_sparse_keys(keys, dimension: dimension)
|
|
29
|
+
populated_keys_values = {}
|
|
30
|
+
default_value = is_nested ? {} : 0
|
|
31
|
+
keys.each do |key|
|
|
32
|
+
populated_keys_values[key] = keys_values[key] || default_value
|
|
30
33
|
end
|
|
31
|
-
|
|
34
|
+
return nested_hash_to_arrays_values(populated_keys_values) if is_nested
|
|
35
|
+
populated_keys_values
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def self.populate_sparse_keys(keys)
|
|
38
|
+
def self.populate_sparse_keys(keys, dimension:)
|
|
35
39
|
return keys if keys.blank?
|
|
36
|
-
first_key = keys.first
|
|
37
|
-
return keys unless first_key.is_a?(Time)
|
|
40
|
+
first_key = dimension.first_key || keys.first
|
|
41
|
+
return keys unless first_key.is_a?(Time) || first_key.is_a?(Date)
|
|
42
|
+
granularity = dimension.granularity
|
|
43
|
+
|
|
44
|
+
first_key = first_key.beginning_of_week if granularity == 'week'
|
|
38
45
|
keys = keys.sort
|
|
39
|
-
|
|
40
|
-
last_key =
|
|
41
|
-
|
|
46
|
+
last_key = dimension.last_key || keys.last
|
|
47
|
+
last_key = last_key.beginning_of_week if granularity == 'week'
|
|
48
|
+
|
|
49
|
+
if granularity == 'week'
|
|
50
|
+
beginning_of_current_week = Time.now.utc.beginning_of_week(ReportsKit.configuration.first_day_of_week)
|
|
51
|
+
last_key = [beginning_of_current_week, last_key].compact.max
|
|
52
|
+
end
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
date = first_key.to_date
|
|
44
55
|
populated_keys = []
|
|
56
|
+
interval = granularity == 'week' ? 1.week : 1.day
|
|
45
57
|
loop do
|
|
46
|
-
populated_keys <<
|
|
47
|
-
break if
|
|
48
|
-
|
|
58
|
+
populated_keys << date
|
|
59
|
+
break if date >= last_key
|
|
60
|
+
date += interval
|
|
49
61
|
end
|
|
50
62
|
populated_keys
|
|
51
63
|
end
|
|
52
64
|
|
|
65
|
+
def self.arrays_values_to_nested_hash(arrays_values)
|
|
66
|
+
nested_hash = {}
|
|
67
|
+
arrays_values.each do |(key1, key2), value|
|
|
68
|
+
nested_hash[key1] ||= {}
|
|
69
|
+
nested_hash[key1][key2] ||= value
|
|
70
|
+
end
|
|
71
|
+
nested_hash
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.nested_hash_to_arrays_values(nested_hash)
|
|
75
|
+
arrays_values = {}
|
|
76
|
+
nested_hash.each do |key1, key2s_values|
|
|
77
|
+
if key2s_values.blank?
|
|
78
|
+
arrays_values[[key1, nil]] = 0
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
key2s_values.each do |key2, value|
|
|
82
|
+
arrays_values[[key1, key2]] = value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
arrays_values
|
|
86
|
+
end
|
|
87
|
+
|
|
53
88
|
def self.dimension_to_dimension_ids_dimension_instances(dimension, dimension_ids)
|
|
54
89
|
return nil unless dimension.instance_class
|
|
55
90
|
dimension_instances = dimension.instance_class.where(id: dimension_ids.uniq)
|
|
@@ -62,12 +97,12 @@ module ReportsKit
|
|
|
62
97
|
def self.dimension_key_to_label(dimension_instance, dimension, ids_dimension_instances)
|
|
63
98
|
return dimension_instance.to_s if dimension.configured_by_column? && dimension.column_type == :integer
|
|
64
99
|
case dimension_instance
|
|
65
|
-
when Time
|
|
100
|
+
when Time, Date
|
|
66
101
|
Utils.format_time(dimension_instance)
|
|
67
102
|
when Fixnum
|
|
68
103
|
raise ArgumentError.new("ids_dimension_instances must be present for Dimension with identifier: #{dimension_instance}") unless ids_dimension_instances
|
|
69
104
|
instance = ids_dimension_instances[dimension_instance.to_i]
|
|
70
|
-
|
|
105
|
+
return unless instance
|
|
71
106
|
instance.to_s
|
|
72
107
|
else
|
|
73
108
|
dimension_instance.to_s.gsub(/\.0$/, '')
|
|
@@ -2,6 +2,12 @@ module ReportsKit
|
|
|
2
2
|
module Reports
|
|
3
3
|
class Dimension
|
|
4
4
|
DEFAULT_DIMENSION_INSTANCES_LIMIT = 30
|
|
5
|
+
DEFAULT_GRANULARITY = 'week'
|
|
6
|
+
VALID_GRANULARITIES = %w(day week).freeze
|
|
7
|
+
ADAPTER_NAMES_CLASSES = {
|
|
8
|
+
'mysql2' => Adapters::Mysql,
|
|
9
|
+
'postgresql' => Adapters::Postgresql
|
|
10
|
+
}.freeze
|
|
5
11
|
|
|
6
12
|
attr_accessor :properties, :measure, :configuration
|
|
7
13
|
|
|
@@ -26,6 +32,15 @@ module ReportsKit
|
|
|
26
32
|
properties[:key]
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
def granularity
|
|
36
|
+
@granularity ||= begin
|
|
37
|
+
return unless configured_by_time?
|
|
38
|
+
granularity = properties[:granularity] || DEFAULT_GRANULARITY
|
|
39
|
+
raise ArgumentError.new("Invalid granularity: #{granularity}") unless VALID_GRANULARITIES.include?(granularity)
|
|
40
|
+
granularity
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
29
44
|
def label
|
|
30
45
|
key.titleize
|
|
31
46
|
end
|
|
@@ -50,9 +65,9 @@ module ReportsKit
|
|
|
50
65
|
elsif configured_by_association?
|
|
51
66
|
"#{model_class.table_name}.#{reflection.foreign_key}"
|
|
52
67
|
elsif configured_by_column? && configured_by_time?
|
|
53
|
-
|
|
68
|
+
granularity == 'day' ? day_expression : week_expression
|
|
54
69
|
elsif configured_by_column?
|
|
55
|
-
|
|
70
|
+
column_expression
|
|
56
71
|
else
|
|
57
72
|
raise ArgumentError.new('Invalid group_expression')
|
|
58
73
|
end
|
|
@@ -70,9 +85,45 @@ module ReportsKit
|
|
|
70
85
|
end
|
|
71
86
|
end
|
|
72
87
|
|
|
88
|
+
def first_key
|
|
89
|
+
return unless configured_by_time? && datetime_filters.present?
|
|
90
|
+
datetime_filters.map(&:start_at).compact.sort.first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def last_key
|
|
94
|
+
return unless configured_by_time? && datetime_filters.present?
|
|
95
|
+
datetime_filters.map(&:end_at).compact.sort.last
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def datetime_filters
|
|
99
|
+
return [] unless measure.filters.present?
|
|
100
|
+
measure.filters.map(&:filter_type).select { |filter_type| filter_type.is_a?(FilterTypes::Datetime) }
|
|
101
|
+
end
|
|
102
|
+
|
|
73
103
|
def should_be_sorted_by_count?
|
|
74
104
|
!configured_by_time?
|
|
75
105
|
end
|
|
106
|
+
|
|
107
|
+
def adapter
|
|
108
|
+
@adapter ||= begin
|
|
109
|
+
adapter_name = model_class.connection_config[:adapter]
|
|
110
|
+
adapter = ADAPTER_NAMES_CLASSES[adapter_name]
|
|
111
|
+
raise ArgumentError.new("Unsupported adapter: #{adapter_name}") unless adapter
|
|
112
|
+
adapter
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def column_expression
|
|
117
|
+
"#{model_class.table_name}.#{key}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def day_expression
|
|
121
|
+
adapter.truncate_to_day(column_expression)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def week_expression
|
|
125
|
+
adapter.truncate_to_week(column_expression)
|
|
126
|
+
end
|
|
76
127
|
end
|
|
77
128
|
end
|
|
78
129
|
end
|
|
@@ -7,21 +7,36 @@ module ReportsKit
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
def apply_conditions(records)
|
|
10
|
+
case conditions
|
|
11
|
+
when ::String
|
|
12
|
+
records.where("(#{conditions}) #{sql_operator} true")
|
|
13
|
+
when ::Hash
|
|
14
|
+
boolean_operator ? records.where(conditions) : records.not.where(conditions)
|
|
15
|
+
else
|
|
16
|
+
raise ArgumentError.new("Unsupported conditions type: '#{conditions}'")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def boolean_operator
|
|
10
21
|
case criteria[:operator]
|
|
11
22
|
when true, 'true'
|
|
12
|
-
|
|
23
|
+
true
|
|
13
24
|
when false, 'false'
|
|
14
|
-
|
|
25
|
+
false
|
|
15
26
|
else
|
|
16
27
|
raise ArgumentError.new("Unsupported operator: '#{criteria[:operator]}'")
|
|
17
28
|
end
|
|
18
29
|
end
|
|
19
30
|
|
|
31
|
+
def sql_operator
|
|
32
|
+
boolean_operator ? '=' : '!='
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
def valid?
|
|
21
36
|
criteria[:operator].present?
|
|
22
37
|
end
|
|
23
38
|
|
|
24
|
-
def
|
|
39
|
+
def conditions
|
|
25
40
|
settings[:conditions] || properties[:key]
|
|
26
41
|
end
|
|
27
42
|
end
|
|
@@ -9,15 +9,29 @@ module ReportsKit
|
|
|
9
9
|
def apply_conditions(records)
|
|
10
10
|
case criteria[:operator]
|
|
11
11
|
when 'between'
|
|
12
|
-
start_string, end_string = value.split(' - ')
|
|
13
|
-
start_at = Date.parse(start_string)
|
|
14
|
-
end_at = Date.parse(end_string)
|
|
15
12
|
records.where("#{column} IS NOT NULL").where("#{column} BETWEEN ? AND ?", start_at, end_at)
|
|
16
13
|
else
|
|
17
14
|
raise ArgumentError.new("Unsupported operator: '#{criteria[:operator]}'")
|
|
18
15
|
end
|
|
19
16
|
end
|
|
20
17
|
|
|
18
|
+
def start_at_end_at
|
|
19
|
+
@start_at_end_at ||= begin
|
|
20
|
+
start_string, end_string = value.split(' - ')
|
|
21
|
+
start_at = Date.parse(start_string)
|
|
22
|
+
end_at = Date.parse(end_string)
|
|
23
|
+
[start_at, end_at]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start_at
|
|
28
|
+
start_at_end_at[0]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def end_at
|
|
32
|
+
start_at_end_at[1]
|
|
33
|
+
end
|
|
34
|
+
|
|
21
35
|
def valid?
|
|
22
36
|
value.present?
|
|
23
37
|
end
|
|
@@ -12,17 +12,25 @@ module ReportsKit
|
|
|
12
12
|
|
|
13
13
|
def perform
|
|
14
14
|
raise ArgumentError.new("Could not find a model for filter_key: '#{filter_key}'") unless model
|
|
15
|
-
results =
|
|
15
|
+
results = relation
|
|
16
16
|
results = results.public_send(scope) if scope
|
|
17
17
|
results = results.limit(10_000)
|
|
18
18
|
results = results.map { |result| { id: result.id, text: result.to_s } }
|
|
19
19
|
results = results.sort_by { |result| result[:text].downcase }
|
|
20
20
|
results = filter_results(results)
|
|
21
|
-
results
|
|
21
|
+
results.first(100)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
|
+
def relation
|
|
27
|
+
if context_record
|
|
28
|
+
context_record.public_send(model.name.tableize)
|
|
29
|
+
else
|
|
30
|
+
model
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
def filter_results(results)
|
|
27
35
|
query = params[:q].try(:downcase)
|
|
28
36
|
if query.present?
|