reports_kit 0.1.0 → 0.2.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -5
  3. data/app/assets/javascripts/reports_kit/lib/chart.js +33 -8
  4. data/app/assets/javascripts/reports_kit/lib/report.js +27 -26
  5. data/app/assets/javascripts/reports_kit/lib/table.js +58 -0
  6. data/app/assets/javascripts/reports_kit/vendor/jquery.tablesorter.min.js +4 -0
  7. data/app/assets/stylesheets/reports_kit/reports.css.sass +20 -0
  8. data/config/initializers/mime_types.rb +1 -0
  9. data/docs/dimensions.md +34 -26
  10. data/docs/display_options.md +15 -12
  11. data/docs/filters.md +2 -2
  12. data/docs/measures.md +4 -3
  13. data/gemfiles/mysql.gemfile.lock +14 -1
  14. data/gemfiles/postgresql.gemfile.lock +14 -1
  15. data/lib/reports_kit.rb +15 -0
  16. data/lib/reports_kit/cache.rb +37 -0
  17. data/lib/reports_kit/configuration.rb +13 -1
  18. data/lib/reports_kit/helper.rb +54 -3
  19. data/lib/reports_kit/model_configuration.rb +6 -1
  20. data/lib/reports_kit/order.rb +33 -0
  21. data/lib/reports_kit/relative_time.rb +42 -0
  22. data/lib/reports_kit/report_builder.rb +34 -15
  23. data/lib/reports_kit/reports/abstract_measure.rb +9 -0
  24. data/lib/reports_kit/reports/adapters/mysql.rb +8 -1
  25. data/lib/reports_kit/reports/adapters/postgresql.rb +8 -1
  26. data/lib/reports_kit/reports/composite_measure.rb +43 -0
  27. data/lib/reports_kit/reports/data/chart_options.rb +5 -0
  28. data/lib/reports_kit/reports/data/composite_aggregation.rb +96 -0
  29. data/lib/reports_kit/reports/data/entity.rb +7 -0
  30. data/lib/reports_kit/reports/data/format_one_dimension.rb +120 -0
  31. data/lib/reports_kit/reports/data/format_two_dimensions.rb +141 -0
  32. data/lib/reports_kit/reports/data/generate.rb +72 -25
  33. data/lib/reports_kit/reports/data/generate_for_properties.rb +75 -0
  34. data/lib/reports_kit/reports/data/one_dimension.rb +15 -49
  35. data/lib/reports_kit/reports/data/populate_one_dimension.rb +36 -0
  36. data/lib/reports_kit/reports/data/populate_two_dimensions.rb +104 -0
  37. data/lib/reports_kit/reports/data/two_dimensions.rb +15 -110
  38. data/lib/reports_kit/reports/data/utils.rb +77 -12
  39. data/lib/reports_kit/reports/data/value.rb +7 -0
  40. data/lib/reports_kit/reports/dimension.rb +4 -110
  41. data/lib/reports_kit/reports/dimension_with_measure.rb +137 -0
  42. data/lib/reports_kit/reports/filter.rb +5 -64
  43. data/lib/reports_kit/reports/filter_types/base.rb +1 -1
  44. data/lib/reports_kit/reports/filter_types/boolean.rb +9 -7
  45. data/lib/reports_kit/reports/filter_types/datetime.rb +7 -5
  46. data/lib/reports_kit/reports/filter_types/number.rb +2 -0
  47. data/lib/reports_kit/reports/filter_with_measure.rb +84 -0
  48. data/lib/reports_kit/reports/generate_autocomplete_results.rb +1 -1
  49. data/lib/reports_kit/reports/inferrable_configuration.rb +32 -13
  50. data/lib/reports_kit/reports/measure.rb +48 -12
  51. data/lib/reports_kit/reports_controller.rb +42 -3
  52. data/lib/reports_kit/version.rb +1 -1
  53. data/reports_kit.gemspec +2 -0
  54. data/spec/fixtures/generate_inputs.yml +146 -21
  55. data/spec/fixtures/generate_outputs.yml +768 -17
  56. data/spec/reports_kit/relative_time_spec.rb +29 -0
  57. data/spec/reports_kit/report_builder_spec.rb +28 -0
  58. data/spec/reports_kit/reports/data/generate_spec.rb +614 -27
  59. data/spec/reports_kit/reports/dimension_with_measure_spec.rb +69 -0
  60. data/spec/reports_kit/reports/{filter_spec.rb → filter_with_measure_spec.rb} +4 -3
  61. data/spec/spec_helper.rb +7 -2
  62. data/spec/support/config.rb +11 -0
  63. data/spec/support/helpers.rb +3 -3
  64. data/spec/support/models/issue.rb +7 -0
  65. metadata +53 -4
  66. data/spec/reports_kit/reports/dimension_spec.rb +0 -54
@@ -2,16 +2,38 @@ module ReportsKit
2
2
  module Reports
3
3
  module Data
4
4
  class Utils
5
- def self.format_time(time)
5
+ def self.format_display_time(time)
6
6
  time.strftime('%b %-d, \'%y')
7
7
  end
8
8
 
9
+ def self.format_configuration_time(time)
10
+ time.strftime('%b %-d, %Y')
11
+ end
12
+
13
+ def self.format_time_value(value)
14
+ time = RelativeTime.parse(value, prevent_exceptions: true)
15
+ return value unless time
16
+ Utils.format_configuration_time(time)
17
+ end
18
+
19
+ def self.format_number(number)
20
+ number_i = number.to_i
21
+ return number_i if number == number_i
22
+ number.round(Generate::ROUND_PRECISION)
23
+ end
24
+
25
+ def self.parse_date_string(string)
26
+ begin
27
+ Date.parse(string)
28
+ rescue ArgumentError
29
+ RelativeTime.parse(string)
30
+ end
31
+ end
32
+
9
33
  def self.populate_sparse_hash(hash, dimension:)
10
- return hash if hash.blank?
11
34
  keys = hash.keys
12
- is_nested = false
13
- if keys.first.is_a?(Array)
14
- is_nested = true
35
+ is_nested = dimension.measure.has_two_dimensions?
36
+ if is_nested
15
37
  keys_values = arrays_values_to_nested_hash(hash)
16
38
  keys = keys_values.keys
17
39
  else
@@ -36,22 +58,22 @@ module ReportsKit
36
58
  end
37
59
 
38
60
  def self.populate_sparse_keys(keys, dimension:)
39
- return keys if keys.blank?
40
61
  first_key = dimension.first_key || keys.first
41
62
  return keys unless first_key.is_a?(Time) || first_key.is_a?(Date)
63
+ first_key = first_key.to_date
42
64
  granularity = dimension.granularity
43
65
 
44
- first_key = first_key.beginning_of_week if granularity == 'week'
66
+ first_key = first_key.beginning_of_week(ReportsKit.configuration.first_day_of_week) if granularity == 'week'
45
67
  keys = keys.sort
46
- last_key = dimension.last_key || keys.last
47
- last_key = last_key.beginning_of_week if granularity == 'week'
68
+ last_key = (dimension.last_key || keys.last).to_date
69
+ last_key = last_key.beginning_of_week(ReportsKit.configuration.first_day_of_week) if granularity == 'week'
48
70
 
49
71
  if granularity == 'week'
50
- beginning_of_current_week = Time.now.utc.beginning_of_week(ReportsKit.configuration.first_day_of_week)
72
+ beginning_of_current_week = Date.today.beginning_of_week(ReportsKit.configuration.first_day_of_week)
51
73
  last_key = [beginning_of_current_week, last_key].compact.max
52
74
  end
53
75
 
54
- date = first_key.to_date
76
+ date = first_key
55
77
  populated_keys = []
56
78
  interval = granularity == 'week' ? 1.week : 1.day
57
79
  loop do
@@ -94,11 +116,19 @@ module ReportsKit
94
116
  Hash[dimension_ids_dimension_instances]
95
117
  end
96
118
 
119
+ def self.dimension_key_to_entity(dimension_key, dimension, dimension_ids_dimension_instances)
120
+ instance = dimension_ids_dimension_instances ? dimension_ids_dimension_instances[dimension_key] : dimension_key
121
+ label = dimension_key_to_label(dimension_key, dimension, dimension_ids_dimension_instances)
122
+ Entity.new(dimension_key, label, instance)
123
+ end
124
+
97
125
  def self.dimension_key_to_label(dimension_instance, dimension, ids_dimension_instances)
126
+ label = dimension.key_to_label(dimension_instance)
127
+ return label if label
98
128
  return dimension_instance.to_s if dimension.configured_by_column? && dimension.column_type == :integer
99
129
  case dimension_instance
100
130
  when Time, Date
101
- Utils.format_time(dimension_instance)
131
+ Utils.format_display_time(dimension_instance)
102
132
  when Fixnum
103
133
  raise ArgumentError.new("ids_dimension_instances must be present for Dimension with identifier: #{dimension_instance}") unless ids_dimension_instances
104
134
  instance = ids_dimension_instances[dimension_instance.to_i]
@@ -108,6 +138,41 @@ module ReportsKit
108
138
  dimension_instance.to_s.gsub(/\.0$/, '')
109
139
  end
110
140
  end
141
+
142
+ def self.raw_value_to_value(raw_value, value_format_method)
143
+ formatted_value = format_number(raw_value)
144
+ formatted_value = value_format_method.call(raw_value) if value_format_method
145
+ Value.new(raw_value, formatted_value)
146
+ end
147
+
148
+ def self.normalize_filters(measure_properties, ui_filters)
149
+ measure_properties[:filters] = measure_properties[:filters].map do |filter_properties|
150
+ filter_properties = { key: filter_properties } if filter_properties.is_a?(String)
151
+ key = filter_properties[:key]
152
+ ui_key = filter_properties[:ui_key]
153
+ value = ui_filters[key.to_sym]
154
+ value ||= ui_filters[ui_key.to_sym] if ui_key
155
+ if value
156
+ filter_properties[:criteria] ||= {}
157
+ filter_properties[:criteria][:value] = value
158
+ end
159
+ filter_properties
160
+ end
161
+ measure_properties
162
+ end
163
+
164
+ def self.normalize_properties(properties, ui_filters: nil)
165
+ ui_filters ||= properties[:ui_filters]
166
+ properties = properties.dup
167
+ properties[:measures] = [properties.delete(:measure)] if properties[:measure]
168
+ return properties if ui_filters.blank? || properties[:measures].blank?
169
+ properties[:measures] = properties[:measures].map do |measure_properties|
170
+ measure_properties = normalize_properties(measure_properties, ui_filters: ui_filters)
171
+ next(measure_properties) if measure_properties[:filters].blank?
172
+ normalize_filters(measure_properties, ui_filters)
173
+ end
174
+ properties
175
+ end
111
176
  end
112
177
  end
113
178
  end
@@ -0,0 +1,7 @@
1
+ module ReportsKit
2
+ module Reports
3
+ module Data
4
+ Value = Struct.new(:raw, :formatted)
5
+ end
6
+ end
7
+ end
@@ -1,128 +1,22 @@
1
1
  module ReportsKit
2
2
  module Reports
3
3
  class Dimension
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
11
-
12
- attr_accessor :properties, :measure, :configuration
13
-
14
- delegate :configured_by_association?, :configured_by_column?, :configured_by_model?, :configured_by_time?,
15
- :settings_from_model, :reflection, :instance_class, :model_class, :column_type,
16
- to: :configuration
17
-
18
- def initialize(properties, measure:)
19
- self.configuration = InferrableConfiguration.new(self, :dimensions)
20
- self.measure = measure
4
+ attr_accessor :properties
21
5
 
6
+ def initialize(properties)
22
7
  raise ArgumentError.new('Blank properties') if properties.blank?
23
8
  properties = { key: properties } if properties.is_a?(String)
24
- raise ArgumentError.new("Measure properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
9
+ raise ArgumentError.new("Dimension properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
25
10
  properties = properties.deep_symbolize_keys
26
11
  self.properties = properties
27
- missing_group_setting = settings && !settings.key?(:group)
28
- raise ArgumentError.new("Dimension settings for dimension '#{key}' of #{model_class} must include :group") if missing_group_setting
29
12
  end
30
13
 
31
14
  def key
32
15
  properties[:key]
33
16
  end
34
17
 
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
-
44
18
  def label
45
- key.titleize
46
- end
47
-
48
- def settings
49
- inferred_settings.merge(settings_from_model)
50
- end
51
-
52
- def inferred_settings
53
- configuration.inferred_settings.merge(inferred_dimension_settings)
54
- end
55
-
56
- def inferred_dimension_settings
57
- {
58
- group: group_expression
59
- }
60
- end
61
-
62
- def group_expression
63
- if configured_by_model?
64
- settings_from_model[:group]
65
- elsif configured_by_association?
66
- "#{model_class.table_name}.#{reflection.foreign_key}"
67
- elsif configured_by_column? && configured_by_time?
68
- granularity == 'day' ? day_expression : week_expression
69
- elsif configured_by_column?
70
- column_expression
71
- else
72
- raise ArgumentError.new('Invalid group_expression')
73
- end
74
- end
75
-
76
- def joins
77
- settings_from_model[:joins] if configured_by_model?
78
- end
79
-
80
- def dimension_instances_limit
81
- if configured_by_time?
82
- properties[:limit]
83
- else
84
- properties[:limit] || DEFAULT_DIMENSION_INSTANCES_LIMIT
85
- end
86
- end
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
-
103
- def should_be_sorted_by_count?
104
- !configured_by_time?
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)
19
+ properties.key?(:label) ? properties[:label] : key.titleize
126
20
  end
127
21
  end
128
22
  end
@@ -0,0 +1,137 @@
1
+ module ReportsKit
2
+ module Reports
3
+ class DimensionWithMeasure
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
11
+
12
+ attr_accessor :dimension, :measure, :configuration
13
+
14
+ delegate :key, :properties, :label, to: :dimension
15
+ delegate :configured_by_association?, :configured_by_column?, :configured_by_model?, :configured_by_time?,
16
+ :settings_from_model, :reflection, :instance_class, :model_class, :column_type,
17
+ to: :configuration
18
+
19
+ def initialize(dimension:, measure:)
20
+ self.dimension = dimension
21
+ self.measure = measure
22
+ self.configuration = InferrableConfiguration.new(self, :dimensions)
23
+ missing_group_setting = settings && !settings.key?(:group)
24
+ raise ArgumentError.new("Dimension settings for dimension '#{key}' of #{model_class} must include :group") if missing_group_setting
25
+ end
26
+
27
+ def granularity
28
+ @granularity ||= begin
29
+ return unless configured_by_time?
30
+ granularity = properties[:granularity] || DEFAULT_GRANULARITY
31
+ raise ArgumentError.new("Invalid granularity: #{granularity}") unless VALID_GRANULARITIES.include?(granularity)
32
+ granularity
33
+ end
34
+ end
35
+
36
+ def settings
37
+ inferred_settings.merge(settings_from_model)
38
+ end
39
+
40
+ def inferred_settings
41
+ configuration.inferred_settings.merge(inferred_dimension_settings)
42
+ end
43
+
44
+ def inferred_dimension_settings
45
+ {
46
+ group: group_expression
47
+ }
48
+ end
49
+
50
+ def group_expression
51
+ if configured_by_model?
52
+ settings_from_model[:group]
53
+ elsif configured_by_association?
54
+ inferred_settings_from_association[:column]
55
+ elsif configured_by_column? && configured_by_time?
56
+ granularity == 'day' ? day_expression : week_expression
57
+ elsif configured_by_column?
58
+ column_expression
59
+ else
60
+ raise ArgumentError.new('Invalid group_expression')
61
+ end
62
+ end
63
+
64
+ def inferred_settings_from_association
65
+ through_reflection = reflection.through_reflection
66
+ if through_reflection
67
+ {
68
+ joins: through_reflection.name,
69
+ column: "#{through_reflection.table_name}.#{reflection.source_reflection.foreign_key}"
70
+ }
71
+ else
72
+ {
73
+ column: "#{model_class.table_name}.#{reflection.foreign_key}"
74
+ }
75
+ end
76
+ end
77
+
78
+ def joins
79
+ return settings_from_model[:joins] if configured_by_model?
80
+ inferred_settings_from_association[:joins] if configured_by_association?
81
+ end
82
+
83
+ def dimension_instances_limit
84
+ if configured_by_time?
85
+ properties[:limit]
86
+ else
87
+ properties[:limit] || DEFAULT_DIMENSION_INSTANCES_LIMIT
88
+ end
89
+ end
90
+
91
+ def first_key
92
+ return unless configured_by_time? && datetime_filters.present?
93
+ datetime_filters.map(&:start_at).compact.sort.first
94
+ end
95
+
96
+ def last_key
97
+ return unless configured_by_time? && datetime_filters.present?
98
+ datetime_filters.map(&:end_at).compact.sort.last
99
+ end
100
+
101
+ def key_to_label(key)
102
+ return unless settings[:key_to_label]
103
+ settings[:key_to_label].call(key)
104
+ end
105
+
106
+ def datetime_filters
107
+ return [] unless measure.filters.present?
108
+ measure.filters.map(&:filter_type).select { |filter_type| filter_type.is_a?(FilterTypes::Datetime) }
109
+ end
110
+
111
+ def should_be_sorted_by_count?
112
+ !configured_by_time?
113
+ end
114
+
115
+ def adapter
116
+ @adapter ||= begin
117
+ adapter_name = model_class.connection_config[:adapter]
118
+ adapter = ADAPTER_NAMES_CLASSES[adapter_name]
119
+ raise ArgumentError.new("Unsupported adapter: #{adapter_name}") unless adapter
120
+ adapter
121
+ end
122
+ end
123
+
124
+ def column_expression
125
+ "#{model_class.table_name}.#{key}"
126
+ end
127
+
128
+ def day_expression
129
+ adapter.truncate_to_day(column_expression)
130
+ end
131
+
132
+ def week_expression
133
+ adapter.truncate_to_week(column_expression)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -1,35 +1,12 @@
1
1
  module ReportsKit
2
2
  module Reports
3
3
  class Filter
4
- CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES = {
5
- association: FilterTypes::Records,
6
- boolean: FilterTypes::Boolean,
7
- datetime: FilterTypes::Datetime,
8
- integer: FilterTypes::Number,
9
- string: FilterTypes::String
10
- }
11
- COLUMN_TYPES_FILTER_TYPE_CLASSES = {
12
- boolean: FilterTypes::Boolean,
13
- datetime: FilterTypes::Datetime,
14
- integer: FilterTypes::Number,
15
- string: FilterTypes::String
16
- }
17
-
18
- attr_accessor :properties, :measure, :configuration
19
-
20
- delegate :configured_by_association?, :configured_by_column?, :configured_by_model?, :configured_by_time?,
21
- :settings_from_model, :configuration_strategy, :instance_class, :column_type, :column,
22
- to: :configuration
23
-
24
- def initialize(properties, measure:)
25
- self.configuration = InferrableConfiguration.new(self, :filters)
26
- self.measure = measure
4
+ attr_accessor :properties
27
5
 
6
+ def initialize(properties)
28
7
  properties = { key: properties } if properties.is_a?(String)
29
- raise ArgumentError.new("Measure properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
8
+ raise ArgumentError.new("Filter properties must be a String or Hash, not a #{properties.class.name}: #{properties.inspect}") unless properties.is_a?(Hash)
30
9
  self.properties = properties.deep_symbolize_keys
31
- self.properties[:criteria] = filter_type.default_criteria unless self.properties[:criteria]
32
- self.properties = self.properties
33
10
  end
34
11
 
35
12
  def key
@@ -40,44 +17,8 @@ module ReportsKit
40
17
  key.titleize
41
18
  end
42
19
 
43
- def settings
44
- inferred_settings.merge(settings_from_model)
45
- end
46
-
47
- def inferred_settings
48
- configuration.inferred_settings.merge(inferred_filter_settings)
49
- end
50
-
51
- def inferred_filter_settings
52
- {
53
- column: column
54
- }
55
- end
56
-
57
- def type_klass
58
- type_klass_for_configuration_strategy = CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES[configuration_strategy]
59
- return type_klass_for_configuration_strategy if type_klass_for_configuration_strategy
60
- type_klass_for_column_type = COLUMN_TYPES_FILTER_TYPE_CLASSES[column_type]
61
- return type_klass_for_column_type if type_klass_for_column_type
62
- return filter_type_class_from_model if configured_by_model?
63
- raise ArgumentError.new("No configuration found for filter with key: '#{key}'")
64
- end
65
-
66
- def filter_type
67
- type_klass.new(settings, properties)
68
- end
69
-
70
- def filter_type_class_from_model
71
- return unless settings
72
- type_key = settings[:type_key]
73
- raise ArgumentError.new("No type specified for filter with key: '#{key}'") unless type_key
74
- type_class = CONFIGURATION_STRATEGIES_FILTER_TYPE_CLASSES[type_key]
75
- raise ArgumentError.new("Invalid type ('#{type_key}') specified for filter with key: '#{key}'") unless type_class
76
- type_class
77
- end
78
-
79
- def apply(relation)
80
- filter_type.apply_filter(relation)
20
+ def normalized_properties
21
+ properties
81
22
  end
82
23
  end
83
24
  end