reports_kits 0.7.5 → 0.7.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/.rubocop.yml +85 -0
- data/.travis.yml +21 -0
- data/Appraisals +27 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +35 -0
- data/Rakefile +2 -0
- data/app/assets/javascripts/reports_kits/application.js +14 -0
- data/app/assets/javascripts/reports_kits/lib/_init.js +9 -0
- data/app/assets/javascripts/reports_kits/lib/chart.js +73 -0
- data/app/assets/javascripts/reports_kits/lib/report.js +135 -0
- data/app/assets/javascripts/reports_kits/lib/table.js +87 -0
- data/app/assets/javascripts/reports_kits/vendor/chart.js +12269 -0
- data/app/assets/javascripts/reports_kits/vendor/daterangepicker.js +1627 -0
- data/app/assets/javascripts/reports_kits/vendor/jquery.tablesorter.min.js +4 -0
- data/app/assets/javascripts/reports_kits/vendor/moment.js +4040 -0
- data/app/assets/javascripts/reports_kits/vendor/select2.full.js +6436 -0
- data/app/assets/stylesheets/reports_kits/application.css.scss +3 -0
- data/app/assets/stylesheets/reports_kits/reports.css.sass +33 -0
- data/app/assets/stylesheets/reports_kits/select2_overrides.css.sass +7 -0
- data/app/assets/stylesheets/reports_kits/vendor/daterangepicker.css +269 -0
- data/app/assets/stylesheets/reports_kits/vendor/select2-bootstrap.css +721 -0
- data/app/assets/stylesheets/reports_kits/vendor/select2.css +484 -0
- data/config/initializers/mime_types.rb +1 -0
- data/config/routes.rb +10 -0
- data/docs/images/demo.gif +0 -0
- data/docs/images/users_by_created_at.png +0 -0
- data/gemfiles/mysql.gemfile +7 -0
- data/gemfiles/mysql.gemfile.lock +167 -0
- data/gemfiles/postgresql.gemfile +7 -0
- data/gemfiles/postgresql.gemfile.lock +165 -0
- data/gemfiles/postgresql_rails_5.1.4.gemfile +8 -0
- data/gemfiles/postgresql_rails_5.1.4.gemfile.lock +168 -0
- data/gemfiles/rails_4_mysql.gemfile +8 -0
- data/gemfiles/rails_4_mysql.gemfile.lock +165 -0
- data/gemfiles/rails_4_postgresql.gemfile +8 -0
- data/gemfiles/rails_4_postgresql.gemfile.lock +163 -0
- data/gemfiles/rails_5.1.4_postgresql.gemfile +8 -0
- data/gemfiles/rails_5.1.4_postgresql.gemfile.lock +169 -0
- data/gemfiles/rails_5_mysql.gemfile +8 -0
- data/gemfiles/rails_5_mysql.gemfile.lock +171 -0
- data/gemfiles/rails_5_postgresql.gemfile +8 -0
- data/gemfiles/rails_5_postgresql.gemfile.lock +169 -0
- data/lib/reports_kits/base_controller.rb +17 -0
- data/lib/reports_kits/cache.rb +37 -0
- data/lib/reports_kits/configuration.rb +50 -0
- data/lib/reports_kits/engine.rb +21 -0
- data/lib/reports_kits/entity.rb +3 -0
- data/lib/reports_kits/filters_controller.rb +11 -0
- data/lib/reports_kits/form_builder.rb +66 -0
- data/lib/reports_kits/helper.rb +24 -0
- data/lib/reports_kits/model.rb +16 -0
- data/lib/reports_kits/model_configuration.rb +28 -0
- data/lib/reports_kits/normalized_params.rb +16 -0
- data/lib/reports_kits/order.rb +33 -0
- data/lib/reports_kits/relative_time.rb +42 -0
- data/lib/reports_kits/report_builder.rb +88 -0
- data/lib/reports_kits/reports/abstract_series.rb +9 -0
- data/lib/reports_kits/reports/adapters/mysql.rb +26 -0
- data/lib/reports_kits/reports/adapters/postgresql.rb +26 -0
- data/lib/reports_kits/reports/composite_series.rb +48 -0
- data/lib/reports_kits/reports/contextual_filter.rb +19 -0
- data/lib/reports_kits/reports/data/add_table_aggregations.rb +105 -0
- data/lib/reports_kits/reports/data/aggregate_composite.rb +97 -0
- data/lib/reports_kits/reports/data/aggregate_one_dimension.rb +39 -0
- data/lib/reports_kits/reports/data/aggregate_two_dimensions.rb +39 -0
- data/lib/reports_kits/reports/data/chart_data_for_data_method.rb +62 -0
- data/lib/reports_kits/reports/data/chart_options.rb +155 -0
- data/lib/reports_kits/reports/data/format_one_dimension.rb +140 -0
- data/lib/reports_kits/reports/data/format_table.rb +63 -0
- data/lib/reports_kits/reports/data/format_two_dimensions.rb +143 -0
- data/lib/reports_kits/reports/data/generate.rb +156 -0
- data/lib/reports_kits/reports/data/generate_for_properties.rb +97 -0
- data/lib/reports_kits/reports/data/normalize_properties.rb +62 -0
- data/lib/reports_kits/reports/data/populate_one_dimension.rb +54 -0
- data/lib/reports_kits/reports/data/populate_two_dimensions.rb +104 -0
- data/lib/reports_kits/reports/data/utils.rb +178 -0
- data/lib/reports_kits/reports/dimension.rb +27 -0
- data/lib/reports_kits/reports/dimension_with_series.rb +144 -0
- data/lib/reports_kits/reports/filter.rb +29 -0
- data/lib/reports_kits/reports/filter_types/base.rb +48 -0
- data/lib/reports_kits/reports/filter_types/boolean.rb +47 -0
- data/lib/reports_kits/reports/filter_types/datetime.rb +51 -0
- data/lib/reports_kits/reports/filter_types/number.rb +30 -0
- data/lib/reports_kits/reports/filter_types/records.rb +26 -0
- data/lib/reports_kits/reports/filter_types/string.rb +38 -0
- data/lib/reports_kits/reports/filter_with_series.rb +97 -0
- data/lib/reports_kits/reports/generate_autocomplete_method_results.rb +29 -0
- data/lib/reports_kits/reports/generate_autocomplete_results.rb +57 -0
- data/lib/reports_kits/reports/inferrable_configuration.rb +116 -0
- data/lib/reports_kits/reports/model_settings.rb +30 -0
- data/lib/reports_kits/reports/properties.rb +10 -0
- data/lib/reports_kits/reports/properties_to_filter.rb +40 -0
- data/lib/reports_kits/reports/series.rb +121 -0
- data/lib/reports_kits/reports_controller.rb +65 -0
- data/lib/reports_kits/utils.rb +11 -0
- data/lib/reports_kits/value.rb +3 -0
- data/lib/reports_kits/version.rb +3 -0
- data/lib/reports_kits.rb +79 -0
- data/reports_kits.gemspec +26 -0
- data/spec/factories/issue_factory.rb +4 -0
- data/spec/factories/issues_label_factory.rb +4 -0
- data/spec/factories/label_factory.rb +4 -0
- data/spec/factories/pro_repo_factory.rb +5 -0
- data/spec/factories/repo_factory.rb +5 -0
- data/spec/factories/tag_factory.rb +4 -0
- data/spec/fixtures/generate_inputs.yml +254 -0
- data/spec/fixtures/generate_outputs.yml +1216 -0
- data/spec/reports_kit/form_builder_spec.rb +26 -0
- data/spec/reports_kit/relative_time_spec.rb +29 -0
- data/spec/reports_kit/reports/data/generate/contextual_filters_spec.rb +60 -0
- data/spec/reports_kit/reports/data/generate_spec.rb +1371 -0
- data/spec/reports_kit/reports/data/normalize_properties_spec.rb +196 -0
- data/spec/reports_kit/reports/dimension_with_series_spec.rb +67 -0
- data/spec/reports_kit/reports/filter_with_series_spec.rb +39 -0
- data/spec/reports_kit/reports/generate_autocomplete_results_spec.rb +69 -0
- data/spec/spec_helper.rb +77 -0
- data/spec/support/config.rb +41 -0
- data/spec/support/example_data_methods.rb +25 -0
- data/spec/support/factory_girl.rb +5 -0
- data/spec/support/helpers.rb +25 -0
- data/spec/support/models/issue.rb +14 -0
- data/spec/support/models/issues_label.rb +4 -0
- data/spec/support/models/label.rb +5 -0
- data/spec/support/models/pro/repo.rb +5 -0
- data/spec/support/models/pro/special_issue.rb +4 -0
- data/spec/support/models/repo.rb +13 -0
- data/spec/support/models/tag.rb +4 -0
- data/spec/support/schema.rb +39 -0
- metadata +134 -4
@@ -0,0 +1,66 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
class FormBuilder
|
3
|
+
include ActionView::Helpers
|
4
|
+
|
5
|
+
DEFAULT_DATE_RANGE_VALUE = ['-2M', 'now']
|
6
|
+
|
7
|
+
attr_accessor :properties, :additional_params, :context_record, :properties_to_filter
|
8
|
+
|
9
|
+
def initialize(properties, additional_params: nil, context_record: nil)
|
10
|
+
self.properties = properties.deep_symbolize_keys
|
11
|
+
self.additional_params = additional_params
|
12
|
+
self.context_record = context_record
|
13
|
+
self.properties_to_filter = Reports::PropertiesToFilter.new(properties, context_record: context_record)
|
14
|
+
end
|
15
|
+
|
16
|
+
def check_box(filter_key, options = {})
|
17
|
+
filter = properties_to_filter.perform(filter_key)
|
18
|
+
checked = options.key?(:value) ? options[:value] : filter.normalized_properties[:criteria].try(:[], :value) == 'true'
|
19
|
+
check_box_tag(filter_key, '1', checked, options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def date_range(filter_key, options = {})
|
23
|
+
filter = properties_to_filter.perform(filter_key)
|
24
|
+
defaults = { class: 'form-control input-sm date_range_picker' }
|
25
|
+
options = defaults.deep_merge(options)
|
26
|
+
value = options[:value].presence || filter.normalized_properties[:criteria].try(:[], :value).presence
|
27
|
+
value ||= default_date_range_value
|
28
|
+
text_field_tag(filter_key, value, options)
|
29
|
+
end
|
30
|
+
|
31
|
+
def multi_autocomplete(filter_key, options = {})
|
32
|
+
filter = properties_to_filter.perform(filter_key)
|
33
|
+
reports_kit_path = Rails.application.routes.url_helpers.reports_kit_path
|
34
|
+
path = "#{reports_kit_path}reports_kit/filters/#{filter_key}/autocomplete?"
|
35
|
+
path += additional_params.to_query if additional_params.present?
|
36
|
+
|
37
|
+
defaults = {
|
38
|
+
class: 'form-control input-sm select2',
|
39
|
+
multiple: 'multiple',
|
40
|
+
data: {
|
41
|
+
placeholder: options[:placeholder],
|
42
|
+
path: path
|
43
|
+
}
|
44
|
+
}
|
45
|
+
options = defaults.deep_merge(options)
|
46
|
+
select_tag(filter_key, nil, options)
|
47
|
+
end
|
48
|
+
|
49
|
+
def string_filter(filter_key, options = {})
|
50
|
+
filter = properties_to_filter.perform(filter_key)
|
51
|
+
defaults = { class: 'form-control input-sm' }
|
52
|
+
options = defaults.deep_merge(options)
|
53
|
+
text_field_tag(filter_key, filter.normalized_properties[:criteria].try(:[], :value), options)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def default_date_range_value
|
59
|
+
@default_date_range_value ||= begin
|
60
|
+
start_date = Reports::Data::Utils.format_time_value(DEFAULT_DATE_RANGE_VALUE[0])
|
61
|
+
end_date = Reports::Data::Utils.format_time_value(DEFAULT_DATE_RANGE_VALUE[1])
|
62
|
+
[start_date, Reports::FilterTypes::Datetime::SEPARATOR, end_date].join(' ')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Helper
|
3
|
+
include ReportsKits::NormalizedParams
|
4
|
+
|
5
|
+
def render_report(report_params, context_params: {}, actions: %w(export_csv export_xls), js_report_class: 'Report', &block)
|
6
|
+
report_params = { key: report_params } if report_params.is_a?(String)
|
7
|
+
params.merge!(context_params: context_params, report_params: report_params)
|
8
|
+
properties = Reports::Properties.generate(self)
|
9
|
+
builder = ReportBuilder.new(
|
10
|
+
report_params: report_params,
|
11
|
+
context_params: context_params,
|
12
|
+
actions: actions,
|
13
|
+
js_report_class: js_report_class,
|
14
|
+
properties: properties,
|
15
|
+
view_context: self,
|
16
|
+
block: block
|
17
|
+
)
|
18
|
+
capture do
|
19
|
+
capture(builder, &block) if block
|
20
|
+
builder.render
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Model
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
class << self
|
7
|
+
attr_accessor :reports_kit_configuration
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.reports_kit(&block)
|
11
|
+
self.reports_kit_configuration = ModelConfiguration.new
|
12
|
+
reports_kit_configuration.instance_eval(&block)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
class ModelConfiguration
|
3
|
+
attr_accessor :aggregations, :contextual_filters, :dimensions, :filters
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
self.aggregations = []
|
7
|
+
self.contextual_filters = []
|
8
|
+
self.dimensions = []
|
9
|
+
self.filters = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def aggregation(key, expression, properties = {})
|
13
|
+
aggregations << { key: key.to_s, expression: expression }.merge(properties).symbolize_keys
|
14
|
+
end
|
15
|
+
|
16
|
+
def contextual_filter(key, method)
|
17
|
+
contextual_filters << { key: key, method: method }
|
18
|
+
end
|
19
|
+
|
20
|
+
def dimension(key, properties)
|
21
|
+
dimensions << { key: key.to_s }.merge(properties).symbolize_keys
|
22
|
+
end
|
23
|
+
|
24
|
+
def filter(key, type_key, properties)
|
25
|
+
filters << { key: key.to_s, type_key: type_key }.merge(properties).symbolize_keys
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module NormalizedParams
|
3
|
+
def report_params
|
4
|
+
params[:report_params]
|
5
|
+
end
|
6
|
+
|
7
|
+
def context_params
|
8
|
+
params[:context_params]
|
9
|
+
end
|
10
|
+
|
11
|
+
def report_key
|
12
|
+
raise ArgumentError.new('Blank report_params') if report_params.blank?
|
13
|
+
report_params[:key]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
class Order
|
3
|
+
attr_accessor :relation, :field, :direction
|
4
|
+
|
5
|
+
VALID_RELATIONS = %w(count dimension1 dimension2)
|
6
|
+
VALID_FIELDS = [nil, 'label']
|
7
|
+
VALID_DIRECTIONS = %w(asc desc)
|
8
|
+
|
9
|
+
def initialize(relation, field, direction)
|
10
|
+
self.relation = relation
|
11
|
+
self.field = field
|
12
|
+
self.direction = direction
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.parse(string)
|
16
|
+
string ||= ''
|
17
|
+
field_expression, direction = string.to_s.split(/\s+/)
|
18
|
+
relation, field = (field_expression || '').split('.')
|
19
|
+
|
20
|
+
relation = relation.presence
|
21
|
+
field = field.presence
|
22
|
+
direction = direction.presence || 'asc'
|
23
|
+
|
24
|
+
relation = relation.to_i if relation =~ /^\d+$/
|
25
|
+
|
26
|
+
raise ArgumentError.new("Invalid relation: #{relation}") unless VALID_RELATIONS.include?(relation) || relation.is_a?(Fixnum)
|
27
|
+
raise ArgumentError.new("Invalid field: #{field}") unless VALID_FIELDS.include?(field)
|
28
|
+
raise ArgumentError.new("Invalid direction: #{direction}") unless VALID_DIRECTIONS.include?(direction)
|
29
|
+
|
30
|
+
new(relation, field, direction)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
class RelativeTime
|
3
|
+
LETTERS_DURATION_METHODS = {
|
4
|
+
'y' => :years,
|
5
|
+
'M' => :months,
|
6
|
+
'w' => :weeks,
|
7
|
+
'd' => :days,
|
8
|
+
'h' => :hours,
|
9
|
+
'm' => :minutes,
|
10
|
+
's' => :seconds
|
11
|
+
}
|
12
|
+
LETTERS = LETTERS_DURATION_METHODS.keys.join
|
13
|
+
|
14
|
+
def self.parse(string, prevent_exceptions: false)
|
15
|
+
return Time.zone.now if string == 'now'
|
16
|
+
original_string = string
|
17
|
+
string = string.to_s.strip
|
18
|
+
is_negative = string[0, 1] == '-'
|
19
|
+
string = string[1..-1] if is_negative
|
20
|
+
|
21
|
+
result_string = is_negative ? '-' : ''
|
22
|
+
result_durations = []
|
23
|
+
|
24
|
+
string.scan(/(\d+)([#{LETTERS}]?)/) do |number, letter|
|
25
|
+
result_string += "#{number}#{letter}"
|
26
|
+
duration_method = LETTERS_DURATION_METHODS[letter]
|
27
|
+
unless duration_method
|
28
|
+
return if prevent_exceptions
|
29
|
+
raise ArgumentError.new("Invalid duration letter: #{letter.inspect}")
|
30
|
+
end
|
31
|
+
result_durations << number.to_i.public_send(duration_method)
|
32
|
+
end
|
33
|
+
|
34
|
+
if result_string == '-' || result_string != original_string.to_s.strip
|
35
|
+
return if prevent_exceptions
|
36
|
+
raise ArgumentError.new("Invalid time duration: #{original_string.inspect}")
|
37
|
+
end
|
38
|
+
duration = result_durations.reduce(&:+)
|
39
|
+
is_negative ? duration.ago : duration.from_now
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
class ReportBuilder
|
3
|
+
include ActionView::Helpers
|
4
|
+
|
5
|
+
attr_accessor :report_params, :context_params, :additional_params, :actions, :js_report_class, :properties, :view_context, :block, :form_builder
|
6
|
+
|
7
|
+
ACTION_KEYS_METHODS = {
|
8
|
+
'export_csv' => :export_csv_button,
|
9
|
+
'export_xls' => :export_xls_button
|
10
|
+
}
|
11
|
+
|
12
|
+
def initialize(report_params:, context_params: {}, actions: %w(export_csv export_xls), js_report_class: 'Report', properties:, view_context:, block: nil)
|
13
|
+
self.report_params = report_params.is_a?(String) ? { key: report_params } : report_params
|
14
|
+
self.context_params = context_params
|
15
|
+
self.additional_params = { context_params: context_params, report_params: self.report_params }
|
16
|
+
self.actions = actions
|
17
|
+
self.js_report_class = js_report_class
|
18
|
+
self.view_context = view_context
|
19
|
+
self.block = block
|
20
|
+
self.properties = properties
|
21
|
+
context_record = ReportsKits.configuration.context_record(view_context)
|
22
|
+
self.form_builder = ReportsKits::FormBuilder.new(properties, additional_params: additional_params, context_record: context_record)
|
23
|
+
end
|
24
|
+
|
25
|
+
def render
|
26
|
+
data = { properties: properties.slice(:format), path: reports_data_path, report_class: js_report_class }
|
27
|
+
view_context.content_tag :div, nil, class: 'reports_kit_report form-inline', data: data do
|
28
|
+
elements = []
|
29
|
+
elements << view_context.capture(self, &block) if block
|
30
|
+
elements << view_context.content_tag(:div, nil, class: 'reports_kit_visualization')
|
31
|
+
elements << action_elements_container
|
32
|
+
elements.compact.join.html_safe
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def form(&block)
|
37
|
+
raise ArgumentError.new('No block given for ReportBuilder#form') unless block
|
38
|
+
view_context.form_tag(reports_data_path, method: 'get', class: 'reports_kit_report_form') do
|
39
|
+
view_context.capture(form_builder, &block)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def export_csv_button(text = 'Download CSV', options = {}, &block)
|
44
|
+
export_button(text, 'csv', options, &block)
|
45
|
+
end
|
46
|
+
|
47
|
+
def export_xls_button(text = 'Download Excel', options = {}, &block)
|
48
|
+
export_button(text, 'xls', options, &block)
|
49
|
+
end
|
50
|
+
|
51
|
+
def export_button(text, format, options, &block)
|
52
|
+
data = {
|
53
|
+
role: 'reports_kit_export_button',
|
54
|
+
path: view_context.reports_kit.reports_kit_reports_path({ format: format }.merge(additional_params))
|
55
|
+
}
|
56
|
+
options = { class: 'btn btn-primary', data: data }.merge(options)
|
57
|
+
if block_given?
|
58
|
+
view_context.link_to('#', options, &block)
|
59
|
+
else
|
60
|
+
view_context.link_to(text, '#', options)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def reports_data_path
|
67
|
+
@reports_data_path ||= view_context.reports_kit.reports_kit_reports_path({ format: 'json' }.merge(additional_params))
|
68
|
+
end
|
69
|
+
|
70
|
+
def action_elements_container
|
71
|
+
return if action_elements.blank?
|
72
|
+
view_context.content_tag(:div, nil, class: 'reports_kit_actions') do
|
73
|
+
action_elements.map { |element| view_context.concat(element) }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def action_elements
|
78
|
+
@action_elements ||= begin
|
79
|
+
return if actions.blank?
|
80
|
+
actions.map do |action|
|
81
|
+
element_method = ACTION_KEYS_METHODS[action]
|
82
|
+
raise ArgumentError.new("Invalid action: #{action}") unless element_method
|
83
|
+
send(element_method)
|
84
|
+
end.compact
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Reports
|
3
|
+
module Adapters
|
4
|
+
class Mysql
|
5
|
+
def self.truncate_to_day(column)
|
6
|
+
"DATE(#{column})"
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.truncate_to_week(column)
|
10
|
+
case ReportsKits.configuration.first_day_of_week
|
11
|
+
when :sunday
|
12
|
+
"DATE_SUB(DATE(#{column}), INTERVAL DAYOFWEEK(#{column}) - 1 DAY)"
|
13
|
+
when :monday
|
14
|
+
"DATE_SUB(DATE(#{column}), INTERVAL DAYOFWEEK(#{column}) - 2 DAY)"
|
15
|
+
else
|
16
|
+
raise ArgumentError.new("Unsupported first_day_of_week: #{ReportsKits.configuration.first_day_of_week}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.truncate_to_month(column)
|
21
|
+
"DATE_SUB(DATE(#{column}), INTERVAL DAY(#{column}) - 1 DAY)"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module ReportsKits
|
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
|
+
case ReportsKits.configuration.first_day_of_week
|
11
|
+
when :sunday
|
12
|
+
"DATE_TRUNC('week', #{column}::timestamp + '1 day'::interval) - '1 day'::interval"
|
13
|
+
when :monday
|
14
|
+
"DATE_TRUNC('week', #{column}::timestamp)"
|
15
|
+
else
|
16
|
+
raise ArgumentError.new("Unsupported first_day_of_week: #{ReportsKits.configuration.first_day_of_week}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.truncate_to_month(column)
|
21
|
+
"DATE_TRUNC('month', #{column}::date)"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Reports
|
3
|
+
class CompositeSeries < AbstractSeries
|
4
|
+
attr_accessor :properties, :context_record
|
5
|
+
|
6
|
+
def initialize(properties, context_record:)
|
7
|
+
self.properties = properties.dup
|
8
|
+
self.context_record = context_record
|
9
|
+
end
|
10
|
+
|
11
|
+
def label
|
12
|
+
name
|
13
|
+
end
|
14
|
+
|
15
|
+
def name
|
16
|
+
properties[:name]
|
17
|
+
end
|
18
|
+
|
19
|
+
def composite_operator
|
20
|
+
properties[:composite_operator]
|
21
|
+
end
|
22
|
+
|
23
|
+
def limit
|
24
|
+
properties[:limit]
|
25
|
+
end
|
26
|
+
|
27
|
+
def serieses
|
28
|
+
@serieses ||= Reports::Series.new_from_properties!(properties, context_record: context_record)
|
29
|
+
end
|
30
|
+
|
31
|
+
def filters
|
32
|
+
serieses.map(&:filters).flatten
|
33
|
+
end
|
34
|
+
|
35
|
+
def primary_series
|
36
|
+
serieses.first
|
37
|
+
end
|
38
|
+
|
39
|
+
def dimensions
|
40
|
+
primary_series.dimensions
|
41
|
+
end
|
42
|
+
|
43
|
+
def model_class
|
44
|
+
primary_series.model_class
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Reports
|
3
|
+
class ContextualFilter
|
4
|
+
attr_accessor :key, :model_settings
|
5
|
+
|
6
|
+
delegate :settings_from_model, to: :model_settings
|
7
|
+
|
8
|
+
def initialize(key, model_class)
|
9
|
+
self.key = key.to_sym
|
10
|
+
self.model_settings = ModelSettings.new(model_class, :contextual_filters, self.key)
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply(relation, context_params)
|
14
|
+
raise ArgumentError.new("contextual_filter with key :#{key} not defined in #{model_class}") if settings_from_model.blank?
|
15
|
+
settings_from_model[:method].call(relation, context_params)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Reports
|
3
|
+
module Data
|
4
|
+
class AddTableAggregations
|
5
|
+
attr_accessor :data, :report_options
|
6
|
+
|
7
|
+
VALID_AGGREGATION_OPERATORS = [:sum]
|
8
|
+
|
9
|
+
def initialize(data, report_options:)
|
10
|
+
self.data = data
|
11
|
+
self.report_options = report_options || {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def perform
|
15
|
+
data_with_aggregations
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def data_with_aggregations
|
21
|
+
return data if row_aggregation_configs.blank? && column_aggregation_configs.blank?
|
22
|
+
{
|
23
|
+
entities: entities,
|
24
|
+
datasets: datasets
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
def entities
|
29
|
+
column_aggregation_entities = column_aggregation_configs.map do |config|
|
30
|
+
ReportsKits::Entity.new(config[:label], config[:label], config[:label])
|
31
|
+
end
|
32
|
+
entities_with_row_aggregations + column_aggregation_entities
|
33
|
+
end
|
34
|
+
|
35
|
+
def datasets
|
36
|
+
datasets_with_row_aggregations.map do |dataset|
|
37
|
+
column_aggregation_configs.each do |config|
|
38
|
+
value = aggregate_array(dataset[:values].map(&:formatted), config[:operator])
|
39
|
+
dataset[:values] << ReportsKits::Value.new(value, value)
|
40
|
+
end
|
41
|
+
dataset
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def entities_with_row_aggregations
|
46
|
+
@entities_with_row_aggregations ||= begin
|
47
|
+
return original_entities if row_aggregation_configs.blank?
|
48
|
+
row_aggregation_entities = row_aggregation_configs.map do |config|
|
49
|
+
ReportsKits::Entity.new(config[:label], config[:label], config[:label])
|
50
|
+
end
|
51
|
+
original_entities + row_aggregation_entities
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def datasets_with_row_aggregations
|
56
|
+
@datasets_with_row_aggregations ||= begin
|
57
|
+
return original_datasets if row_aggregation_configs.blank?
|
58
|
+
row_aggregation_datasets = row_aggregation_configs.map do |config|
|
59
|
+
values = original_datasets.map { |dataset| dataset[:values].map(&:formatted) }.transpose
|
60
|
+
aggregated_values = aggregate_array_of_arrays(values, config[:operator])
|
61
|
+
values = aggregated_values.map { |value| ReportsKits::Value.new(value, value) }
|
62
|
+
{
|
63
|
+
entity: ReportsKits::Entity.new(config[:label], config[:label], config[:label]),
|
64
|
+
values: values
|
65
|
+
}
|
66
|
+
end
|
67
|
+
original_datasets + row_aggregation_datasets
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def original_entities
|
72
|
+
data[:entities]
|
73
|
+
end
|
74
|
+
|
75
|
+
def original_datasets
|
76
|
+
data[:datasets]
|
77
|
+
end
|
78
|
+
|
79
|
+
def row_aggregation_configs
|
80
|
+
return [] if report_options[:aggregations].blank?
|
81
|
+
report_options[:aggregations].select { |config| config[:from] == 'rows' }
|
82
|
+
end
|
83
|
+
|
84
|
+
def column_aggregation_configs
|
85
|
+
return [] if report_options[:aggregations].blank?
|
86
|
+
report_options[:aggregations].select { |config| config[:from] == 'columns' }
|
87
|
+
end
|
88
|
+
|
89
|
+
def aggregate_array(values, operator)
|
90
|
+
operator = operator.try(:to_sym)
|
91
|
+
raise ArgumentError.new("Invalid aggregation operator: #{operator}") unless operator.in?(VALID_AGGREGATION_OPERATORS)
|
92
|
+
if values.first.is_a?(Numeric)
|
93
|
+
values.public_send(operator)
|
94
|
+
else
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def aggregate_array_of_arrays(array_of_arrays, operator)
|
100
|
+
array_of_arrays.map { |values| aggregate_array(values, operator) }
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module ReportsKits
|
2
|
+
module Reports
|
3
|
+
module Data
|
4
|
+
class AggregateComposite
|
5
|
+
attr_accessor :composite_series, :context_record
|
6
|
+
|
7
|
+
delegate :composite_operator, :properties, to: :composite_series
|
8
|
+
|
9
|
+
OPERATORS_METHODS = {
|
10
|
+
'+' => :+,
|
11
|
+
'-' => :-,
|
12
|
+
'*' => :*,
|
13
|
+
'/' => :/,
|
14
|
+
'%' => -> (values) {
|
15
|
+
raise ArgumentError.new('Percentage composite aggregations must have exactly two series') if values.length != 2
|
16
|
+
numerator, denominator = values
|
17
|
+
return 0 if denominator == 0
|
18
|
+
((numerator.to_f / denominator) * 100).round(1)
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
def initialize(properties, context_record:)
|
23
|
+
self.composite_series = CompositeSeries.new(properties, context_record: context_record)
|
24
|
+
self.context_record = context_record
|
25
|
+
end
|
26
|
+
|
27
|
+
def perform
|
28
|
+
return serieses_results_for_one_dimension if dimension_count == 1
|
29
|
+
return serieses_results_for_two_dimensions if dimension_count == 2
|
30
|
+
raise ArgumentError.new("Composite aggregations' series can only have 1-2 dimensions")
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def serieses_results_for_one_dimension
|
36
|
+
serieses_results = Hash[serieses.map { |series| [series, AggregateOneDimension.new(series).perform] }]
|
37
|
+
serieses_results = Data::PopulateOneDimension.new(serieses_results, context_record: context_record, properties: properties).perform
|
38
|
+
sorted_dimension_keys_values = sort_dimension_keys_values(serieses_results)
|
39
|
+
value_lists = sorted_dimension_keys_values.map(&:values)
|
40
|
+
composited_values = value_lists.transpose.map { |data| reduce(data) }
|
41
|
+
dimension_keys = sorted_dimension_keys_values.first.keys
|
42
|
+
composited_keys_values = dimension_keys.zip(composited_values)
|
43
|
+
Hash[composited_keys_values]
|
44
|
+
end
|
45
|
+
|
46
|
+
def serieses_results_for_two_dimensions
|
47
|
+
serieses_results = Hash[serieses.map { |series| [series, AggregateTwoDimensions.new(series).perform] }]
|
48
|
+
serieses_results = Data::PopulateTwoDimensions.new(serieses_results).perform
|
49
|
+
value_lists = serieses_results.values.map(&:values)
|
50
|
+
composited_values = value_lists.transpose.map { |data| reduce(data) }
|
51
|
+
dimension_keys = serieses_results.values.first.keys
|
52
|
+
composited_keys_values = dimension_keys.zip(composited_values)
|
53
|
+
Hash[composited_keys_values]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Before performing a composition of values, we need to make sure that the values are sorted by the same dimension key.
|
57
|
+
def sort_dimension_keys_values(serieses_results)
|
58
|
+
dimension_keys_values_list = serieses_results.values
|
59
|
+
sorted_dimension_keys_values = dimension_keys_values_list.map do |dimension_keys_values|
|
60
|
+
dimension_keys_values = dimension_keys_values.sort_by do |dimension_key, value|
|
61
|
+
is_boolean = dimension_key.is_a?(TrueClass) || dimension_key.is_a?(FalseClass)
|
62
|
+
is_boolean ? (dimension_key ? 0 : 1) : dimension_key
|
63
|
+
end
|
64
|
+
Hash[dimension_keys_values]
|
65
|
+
end
|
66
|
+
sorted_dimension_keys_values
|
67
|
+
end
|
68
|
+
|
69
|
+
def reduce(values)
|
70
|
+
if composite_method.is_a?(Symbol)
|
71
|
+
values.reduce(&composite_method)
|
72
|
+
elsif composite_method.is_a?(Proc)
|
73
|
+
values = composite_method.call(values)
|
74
|
+
else
|
75
|
+
raise ArgumentError.new("Invalid composite method type: #{composite_method.class}")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def composite_method
|
80
|
+
composite_method = OPERATORS_METHODS[composite_operator]
|
81
|
+
raise ArgumentError.new("Invalid composite_operator: #{composite_operator}") unless composite_method
|
82
|
+
composite_method
|
83
|
+
end
|
84
|
+
|
85
|
+
def dimension_count
|
86
|
+
unique_dimension_counts = serieses.map { |series| series.dimensions.length }.uniq
|
87
|
+
raise ArgumentError.new('All series must have the same number of dimensions') if unique_dimension_counts.length > 1
|
88
|
+
unique_dimension_counts.first
|
89
|
+
end
|
90
|
+
|
91
|
+
def serieses
|
92
|
+
@serieses ||= Series.new_from_properties!(properties, context_record: context_record)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|