simple_drilldown 0.0.3 → 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.
Files changed (62) hide show
  1. checksums.yaml +5 -5
  2. data/{LICENSE.txt → MIT-LICENSE} +1 -3
  3. data/README.md +15 -16
  4. data/Rakefile +32 -0
  5. data/app/assets/config/simple_drilldown_manifest.js +1 -0
  6. data/app/assets/stylesheets/simple_drilldown/application.css +121 -0
  7. data/app/controllers/simple_drilldown/application_controller.rb +7 -0
  8. data/app/helpers/simple_drilldown/application_helper.rb +6 -0
  9. data/app/jobs/simple_drilldown/application_job.rb +6 -0
  10. data/app/mailers/simple_drilldown/application_mailer.rb +8 -0
  11. data/app/models/simple_drilldown/application_record.rb +7 -0
  12. data/app/views/drilldown/_chart.html.erb +57 -0
  13. data/app/views/drilldown/_excel_record_list.builder +7 -0
  14. data/app/views/drilldown/_excel_row.builder +24 -0
  15. data/app/views/drilldown/_excel_row_header.builder +10 -0
  16. data/app/views/drilldown/_excel_styles.builder +69 -0
  17. data/app/views/drilldown/_excel_summary_row.builder +16 -0
  18. data/app/views/drilldown/_excel_summary_total_row.builder +14 -0
  19. data/app/views/drilldown/_field.html.erb +39 -0
  20. data/app/views/drilldown/_fields.html.erb +12 -0
  21. data/app/views/drilldown/_filter.html.erb +20 -0
  22. data/app/views/drilldown/_record_list.html.erb +12 -0
  23. data/app/views/drilldown/_row.html.erb +13 -0
  24. data/app/views/drilldown/_row_header.html.erb +8 -0
  25. data/app/views/drilldown/_summary_row.html.erb +18 -0
  26. data/app/views/drilldown/_summary_table.html.erb +12 -0
  27. data/app/views/drilldown/_summary_total_row.html.erb +4 -0
  28. data/app/views/drilldown/_tab_buttons.html.erb +5 -0
  29. data/app/views/drilldown/data_0.builder +10 -0
  30. data/app/views/drilldown/data_1.builder +11 -0
  31. data/app/views/drilldown/data_2.builder +37 -0
  32. data/app/views/drilldown/data_3.builder +37 -0
  33. data/app/views/drilldown/excel_export.builder +42 -0
  34. data/app/views/drilldown/excel_export_transactions.builder +90 -0
  35. data/app/views/drilldown/html_export.html.erb +6 -0
  36. data/app/views/drilldown/index.html.erb +107 -0
  37. data/app/views/drilldown/print.html.erb +18 -0
  38. data/app/views/layouts/simple_drilldown/application.html.erb +15 -0
  39. data/config/locales/en.yml +23 -0
  40. data/config/locales/nb.yml +20 -0
  41. data/config/routes.rb +4 -0
  42. data/lib/generators/drilldown_controller/USAGE +8 -0
  43. data/lib/generators/drilldown_controller/drilldown_controller_generator.rb +8 -0
  44. data/lib/generators/drilldown_controller/templates/drilldown_controller.rb.erb +41 -0
  45. data/lib/simple_drilldown.rb +3 -3
  46. data/lib/simple_drilldown/drilldown_controller.rb +389 -274
  47. data/lib/simple_drilldown/drilldown_helper.rb +68 -0
  48. data/lib/simple_drilldown/engine.rb +11 -0
  49. data/lib/simple_drilldown/search.rb +12 -1
  50. data/lib/simple_drilldown/version.rb +1 -1
  51. data/lib/tasks/simple_drilldown_tasks.rake +5 -0
  52. metadata +85 -34
  53. data/.document +0 -5
  54. data/.gitignore +0 -22
  55. data/Gemfile +0 -3
  56. data/Gemfile.lock +0 -89
  57. data/README.rdoc +0 -19
  58. data/lib/sample_drilldown_controller.rb +0 -95
  59. data/lib/simple_drilldown/simple_drilldown_helper.rb +0 -66
  60. data/simple_drilldown.gemspec +0 -24
  61. data/test/helper.rb +0 -18
  62. data/test/test_simple_drilldown.rb +0 -7
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
2
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
3
+
4
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
5
+ <%= render 'layouts/headers' %>
6
+ <%= stylesheet_link_tag 'drilldown' %>
7
+ <body>
8
+ <style type="text/css">
9
+ @media print {#print_link {display: none}}
10
+ </style>
11
+ <div class="container">
12
+ <div id="print_link" class="pull-right">
13
+ <%= link_to '<i class="btn btn-primary glyphicon glyphicon-print"> Print</i>'.html_safe, '#', onclick: 'window.print();' %>
14
+ </div>
15
+ <%= yield %>
16
+ </div>
17
+ </body>
18
+ </html>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Simple drilldown</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "simple_drilldown/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
@@ -0,0 +1,23 @@
1
+ en:
2
+ all: All
3
+ bar: Bar
4
+ chart_type: Chart type
5
+ count: Count
6
+ fields: Fields
7
+ filter: Filter
8
+ from_date: From date
9
+ group_by: Group by
10
+ line: Line
11
+ list: List
12
+ none: None
13
+ order_by_value: Order by value
14
+ pie: Pie
15
+ reset: Reset
16
+ search: Search
17
+ show: Show
18
+ then_by: then by
19
+ title: Title
20
+ to_date: To date
21
+
22
+
23
+
@@ -0,0 +1,20 @@
1
+ nb:
2
+ all: Alle
3
+ bar: Stolper
4
+ chart_type: Graftype
5
+ count: Antall
6
+ fields: Felt
7
+ filter: Filter
8
+ from_date: Fra dato
9
+ group_by: Grupper på
10
+ line: Linje
11
+ list: Liste
12
+ none: Ingen
13
+ order_by_value: Sorter på verdi
14
+ pie: Pai
15
+ reset: Nullstill
16
+ search: Søk
17
+ show: Vis
18
+ then_by: så på
19
+ title: Tittel
20
+ to_date: Til dato
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ SimpleDrilldown::Engine.routes.draw do
4
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Create a DrilldownController.
3
+
4
+ Example:
5
+ rails generate drilldown_controller Thing
6
+
7
+ This will create:
8
+ app/controllers/thing_drilldown_controller.rb
@@ -0,0 +1,8 @@
1
+ class DrilldownControllerGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('templates', __dir__)
3
+
4
+ def copy_drilldown_controller_file
5
+ template "drilldown_controller.rb.erb", "app/controllers/#{file_name}_drilldown_controller.rb"
6
+ route "resources(:#{singular_name}_drilldown, only: :index){collection{get :excel_export;get :html_export}}"
7
+ end
8
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>DrilldownController < DrilldownController
4
+ # What fields should be displayed as default when listing actual <%= class_name %> records.
5
+ default_fields %w[created_at updated_at] # TODO(uwe): Read fields from schema?
6
+
7
+ # The main focus of the drilldown
8
+ target_class <%= class_name %>
9
+
10
+ # How should we count the reords?
11
+ select 'count(*) as count'
12
+
13
+ # When listing records, what relations should be included for optimization?
14
+ # list_includes :user, :comments # TODO(uwe): Read relations from schema?
15
+
16
+ # In what order should records be listed?
17
+ list_order '<%= plural_name %>.created_at'
18
+
19
+ # Field definitions when listing records
20
+ field :created_at
21
+ field :updated_at
22
+
23
+ # The "attr_method" option transforms the value from the database to a
24
+ # readable form.
25
+ # field :user, attr_method: ->(post) { post.user.name }
26
+ # field :body, attr_method: ->(post) { post.body[0..32] }
27
+ # field :comments, attr_method: ->(post) { post.comments.count }
28
+
29
+ dimension :calendar_date, 'DATE(<%= plural_name %>.created_at)', interval: true
30
+ dimension :day_of_month, "date_part('day', <%= plural_name %>.created_at)"
31
+ dimension :day_of_week, "CASE WHEN date_part('dow', <%= plural_name %>.created_at) = 0 THEN 7 ELSE date_part('dow', <%= plural_name %>.created_at) END",
32
+ label_method: ->(day_no) { Date::DAYNAMES[day_no.to_i % 7] }
33
+ dimension :hour_of_day, "date_part('hour', <%= plural_name %>.created_at)"
34
+ dimension :month, "date_part('month', <%= plural_name %>.created_at)",
35
+ label_method: ->(month_no) { Date::MONTHNAMES[month_no.to_i] }
36
+ dimension :week, "date_part('week', <%= plural_name %>.created_at)"
37
+ dimension :year, "date_part('year', <%= plural_name %>.created_at)"
38
+
39
+ # dimension :comments, 'SELECT count(*) FROM comments c WHERE c.<%= singular_name %>_id = <%= plural_name %>.id'
40
+ # dimension :user, 'users.name', includes: :user
41
+ end
@@ -1,6 +1,6 @@
1
- require "simple_drilldown/version"
2
- require 'simple_drilldown/search'
3
- require 'simple_drilldown/drilldown_controller'
1
+ # frozen_string_literal: true
2
+
3
+ require 'simple_drilldown/engine'
4
4
 
5
5
  module SimpleDrilldown
6
6
  # Your code goes here...
@@ -1,318 +1,433 @@
1
- module DrilldownController
2
- def initialize(fields, default_fields, target_class, select, list_includes, list_order)
3
- super()
4
- @fields = fields
5
- @default_fields = default_fields
6
- @target_class = target_class
7
- @select = select
8
- @list_includes = list_includes
9
- @list_order = list_order
10
- end
1
+ # frozen_string_literal: true
11
2
 
12
- def xml_export
13
- params[:search][:list] = '1'
14
- index(false)
15
- @transactions = get_transactions(@result)
16
- headers['Content-Type'] = "text/xml"
17
- headers['Content-Disposition'] = 'attachment; filename="transactions.xml"'
18
- render :template => '/transaction_drilldown/xml_export', :layout => false
19
- end
3
+ require 'simple_drilldown/drilldown_helper'
4
+ require 'simple_drilldown/search'
20
5
 
21
- # ?dimension[0]=supplier&dimension[1]=transaction_type&
22
- # filter[year]=2009&filter[supplier][0]=Shell&filter[supplier][1]=Statoil
23
- def index(do_render = true)
24
- @search = Search.new(params[:search], @default_fields)
6
+ module SimpleDrilldown
7
+ class DrilldownController < ::ApplicationController
8
+ helper DrilldownHelper
25
9
 
26
- @transaction_fields = (@search.fields + ((@fields.keys.map { |field_name| field_name.to_s }) - @search.fields))
27
- @transaction_fields_map = @fields
10
+ LIST_LIMIT = 10_000
28
11
 
29
- select = @select.dup
30
- includes = []
12
+ class << self
13
+ def base_condition(base_condition)
14
+ @@base_condition = base_condition
15
+ end
31
16
 
32
- @dimensions = []
33
- select << ", 'All' as value0"
34
- @dimensions += @search.dimensions.map do |dn|
35
- raise "Unknown distribution field: #{@search.dimensions.inspect}" if @dimension_defs[dn].nil?
36
- @dimension_defs[dn]
37
- end
38
- @dimensions.each_with_index do |d, i|
39
- select << ", #{d[:select_expression]} as value#{i + 1}"
40
- includes << d[:includes] if d[:includes]
41
- end
17
+ def base_includes(base_includes)
18
+ @@base_includes = base_includes
19
+ end
42
20
 
43
- conditions, @filter_text, filter_includes = make_conditions(@search.filter)
44
- includes += filter_includes
45
- includes.uniq!
46
- if @search.order_by_value && @dimensions.size <= 1
47
- order = @search.select_value == Search::SelectValue::VOLUME ? 'volume DESC' : 'count DESC'
48
- else
49
- order = @dimensions.map { |d| d[:select_expression] }.join(', ')
50
- order = nil if order.empty?
51
- end
52
- group = @dimensions.map { |d| d[:select_expression] }.join(', ')
53
- group = nil if group.empty?
54
-
55
- rows = @target_class.find(
56
- :all, :select => select, :conditions => conditions,
57
- :joins => make_join(@target_class.name.underscore.to_sym, includes), :group => group,
58
- :order => order
59
- )
60
-
61
- if rows.empty?
62
- @result = {:value => "All", :count => 0, :volume => 0, :volume_compensated => 0, :row_count => 0, :nodes => 0, :rows => []}
63
- else
64
- @result = result_from_rows(rows, 0, 0, ['All'])
65
- end
66
- @search.list = false if @result[:count] > 10000
21
+ def base_group(base_group)
22
+ @@base_group = base_group
23
+ end
67
24
 
68
- @remaining_dimensions = @dimension_defs.dup.delete_if { |dim_name, dimension| @search.filter[dim_name] && @search.filter[dim_name].size == 1 }
69
- populate_list(conditions, includes, @result, []) if @search.list
70
- render :template => '/transaction_drilldown/index' if do_render
71
- end
25
+ def default_fields(fields)
26
+ @@default_fields = fields
27
+ end
72
28
 
73
- # Empty summary rows are needed to plot zero points in the charts
74
- def add_zero_results(result_rows, dimension)
75
- legal_values = legal_values_for(@dimensions[dimension][:url_param_name], true).call(@search).map { |lv| lv[1] }
76
- current_values = result_rows.map { |r| r[:value] }.compact
77
- empty_values = legal_values - current_values
78
-
79
- unless empty_values.empty?
80
- empty_values.each do |v|
81
- sub_result = {
82
- :value => v,
83
- :count => 0,
84
- :volume => 0,
85
- :volume_compensated => 0,
86
- :row_count => 0,
87
- :nodes => 0,
88
- }
89
- if dimension < @dimensions.size - 1
90
- sub_result[:rows] = add_zero_results([], dimension + 1)
91
- end
92
- result_rows << sub_result
29
+ def target_class(target_class)
30
+ @@target_class = target_class
93
31
  end
94
- result_rows = result_rows.sort_by { |r| legal_values.index(r[:value]) }
95
- end
96
- result_rows
97
- end
98
32
 
99
- def result_from_rows(rows, row_index, dimension, previous_values)
100
- row = rows[row_index]
101
- return nil if row.nil?
102
- values = (0..dimension).to_a.map { |i| row["value#{i}"] }
103
- return nil if values != previous_values
104
-
105
- if dimension == @dimensions.size
106
- return {
107
- :value => values[-1],
108
- :count => row[:count].to_i,
109
- :volume => row[:volume].to_i,
110
- :volume_compensated => row[:volume_compensated].to_i,
111
- :row_count => 1,
112
- :nodes => 1,
113
- }
114
- end
33
+ def select(select)
34
+ @@select = select
35
+ end
115
36
 
116
- result_rows = []
117
- loop do
118
- sub_result = result_from_rows(rows, row_index, dimension + 1, values + [rows[row_index]["value#{dimension + 1}"]])
119
- break if sub_result.nil?
120
- result_rows << sub_result
121
- row_index += sub_result[:row_count]
122
- break if rows[row_index].nil?
123
- end
37
+ def list_includes(list_includes)
38
+ @@list_includes = list_includes
39
+ end
124
40
 
125
- result_rows = add_zero_results(result_rows, dimension)
126
-
127
- {
128
- :value => values[-1],
129
- :count => result_rows.inject(0) { |t, r| t + r[:count].to_i },
130
- :volume => result_rows.inject(0) { |t, r| t + r[:volume] },
131
- :volume_compensated => result_rows.inject(0) { |t, r| t + r[:volume_compensated] },
132
- :row_count => result_rows.inject(0) { |t, r| t + r[:row_count] },
133
- :nodes => result_rows.inject(0) { |t, r| t + r[:nodes] } + 1,
134
- :rows => result_rows,
135
- }
136
- end
41
+ def list_order(list_order)
42
+ @@list_order = list_order
43
+ end
137
44
 
138
- def html_export
139
- index(false)
140
- render :template => '/transaction_drilldown/html_export', :layout => 'print'
141
- end
45
+ def field(name, **options)
46
+ @@fields ||= {}
47
+ @@fields[name] = options
48
+ end
142
49
 
143
- def excel_export
144
- index(false)
145
- headers['Content-Type'] = "application/vnd.ms-excel"
146
- headers['Content-Disposition'] = 'attachment; filename="transactions.xml"'
147
- headers['Cache-Control'] = ''
148
- render :template => '/transaction_drilldown/excel_export', :layout => false
149
- end
50
+ def dimension(name, select_expression = name.to_s, options = {})
51
+ includes = options.delete(:includes)
52
+ interval = options.delete(:interval)
53
+ label_method = options.delete(:label_method) || ->(f) { f.to_s }
54
+ legal_values = options.delete(:legal_values) || legal_values_for(name)
55
+ reverse = options.delete(:reverse)
150
56
 
151
- def excel_export_transactions
152
- params[:search][:list] = '1'
153
- index(false)
154
- @transactions = get_transactions(@result)
155
- headers['Content-Type'] = "application/vnd.ms-excel"
156
- headers['Content-Disposition'] = 'attachment; filename="transactions.xml"'
157
- render :template => '/transaction_drilldown/excel_export_transactions', :layout => false
158
- end
57
+ raise "Unknown options: #{options.keys.inspect}" unless options.empty?
159
58
 
160
- private
59
+ @@dimension_defs ||= Concurrent::Hash.new
161
60
 
162
- def populate_list(conditions, includes, result, values)
163
- if result[:rows]
164
- result[:rows].each do |r|
165
- populate_list(conditions, includes, r, values + [r[:value]])
61
+ @@dimension_defs[name.to_s] = {
62
+ select_expression: select_expression,
63
+ pretty_name: I18n.t(name),
64
+ url_param_name: name.to_s,
65
+ legal_values: legal_values,
66
+ label_method: label_method,
67
+ reverse: reverse,
68
+ includes: includes,
69
+ interval: interval
70
+ }
166
71
  end
167
- else
168
- options = {:include => includes + @list_includes, :order => @list_order}
169
- @search.fields.each do |field|
170
- field_def = @transaction_fields_map[field.to_sym]
171
- raise "Field definition missing for: #{field.inspect}" unless field_def
172
- field_includes = field_def[:include]
173
- if field_includes
174
- options[:include] += field_includes.is_a?(Array) ? field_includes : [field_includes]
72
+
73
+ def legal_values_for(field, preserve_filter = false)
74
+ lambda do |search|
75
+ my_filter = search.filter.dup
76
+ my_filter.delete(field.to_s) unless preserve_filter
77
+ conditions, t, includes = make_conditions(my_filter)
78
+ dimension = @@dimension_defs[field.to_s]
79
+ if dimension[:includes]
80
+ if dimension[:includes].is_a?(Array)
81
+ includes += dimension[:includes]
82
+ else
83
+ includes << dimension[:includes]
84
+ end
85
+ includes.uniq!
86
+ end
87
+ rows = @@target_class.unscoped.where(@@base_condition)
88
+ .select("#{dimension[:select_expression]} AS value")
89
+ .where(conditions)
90
+ .joins(make_join([], @@target_class.name.underscore.to_sym, includes))
91
+ .order('value')
92
+ .group('value').all.to_a
93
+ search.filter[field.to_s]&.each do |selected_value|
94
+ unless rows.find { |r| dimension[:label_method].call(r[:value]) == selected_value }
95
+ rows << { value: selected_value }
96
+ end
97
+ end
98
+ values = rows.map { |r| [dimension[:label_method]&.call(r[:value]) || r[:value], r[:value]] }.sort_by { |a| a[0].upcase }
99
+ values.reverse! if dimension[:reverse]
100
+ values
175
101
  end
176
102
  end
177
- options[:include].uniq!
178
- if @search.last_change_time
179
- options[:include] << {:assignment => {:order => :last_aircraft_registration_change}} if @search.fields.include? 'aircraft_registration'
180
- options[:include] << {:assignment => {:order => :last_fuel_request_change}} if @search.fields.include? 'fuel_request'
103
+
104
+ def make_conditions(search_filter)
105
+ includes = @@base_includes.dup
106
+ if search_filter
107
+ condition_strings = []
108
+ condition_values = []
109
+
110
+ filter_texts = []
111
+ search_filter.each do |field, values|
112
+ dimension_def = @@dimension_defs[field]
113
+ raise "Unknown filter field: #{field.inspect}" if dimension_def.nil?
114
+
115
+ values = [*values]
116
+ if dimension_def[:interval]
117
+ values *= 2 if values.size == 1
118
+ if values.size != 2
119
+ raise "Need 2 values for interval filter: #{values.inspect}"
120
+ end
121
+
122
+ if !values[0].blank? && !values[1].blank?
123
+ condition_strings << "#{dimension_def[:select_expression]} BETWEEN ? AND ?"
124
+ condition_values += values
125
+ filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(values) : "from #{values[0]} to #{values[1]}"}"
126
+ elsif !values[0].blank?
127
+ condition_strings << "#{dimension_def[:select_expression]} >= ?"
128
+ condition_values < values[0]
129
+ filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(values) : "from #{values[0]}"}"
130
+ elsif !values[1].blank?
131
+ condition_strings << "#{dimension_def[:select_expression]} <= ?"
132
+ condition_values < values[1]
133
+ filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(values) : "to #{values[1]}"}"
134
+ end
135
+ includes << dimension_def[:includes] if dimension_def[:includes]
136
+ else
137
+ condition_strings << values.map do |value|
138
+ if dimension_def[:condition_values_method]
139
+ condition_values += dimension_def[:condition_values_method].call(value)
140
+ else
141
+ condition_values << value
142
+ end
143
+ filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(value) : value}"
144
+ includes << dimension_def[:includes] if dimension_def[:includes]
145
+ "(#{dimension_def[:select_expression]}) = ?"
146
+ end.join(' OR ')
147
+ end
148
+ end
149
+ filter_text = filter_texts.join(' and ')
150
+ conditions = [condition_strings.map { |c| "(#{c})" }.join(' AND '), *condition_values]
151
+ includes.uniq!
152
+ else
153
+ filter_text = nil
154
+ conditions = nil
155
+ end
156
+ [conditions, filter_text, includes]
181
157
  end
182
- result[:transactions] = @target_class.all(options.update(:conditions => list_conditions(conditions, values)))
183
- end
184
- end
185
158
 
186
- def list_conditions(conditions, values)
187
- list_conditions_string = conditions[0].dup
188
- @dimensions.each do |d|
189
- list_conditions_string << "#{' AND ' unless list_conditions_string.empty?}#{d[:select_expression]} = ?"
190
- end
191
- [list_conditions_string, *(conditions[1..-1] + values)]
192
- end
159
+ def make_join(joins, model, include, model_class = model.to_s.camelize.constantize)
160
+ case include
161
+ when Array
162
+ include.map { |i| make_join(joins, model, i) }.join(' ')
163
+ when Hash
164
+ sql = ''
165
+ include.each do |parent, child|
166
+ sql << make_join(joins, model, parent) + ' '
167
+ ass = model.to_s.camelize.constantize.reflect_on_association parent
168
+ sql << make_join(joins, parent, child, ass.class_name.constantize)
169
+ end
170
+ sql
171
+ when Symbol
172
+ return '' if joins.include?(include)
173
+
174
+ joins << include
175
+ ass = model_class.reflect_on_association include
176
+ raise "Unknown association: #{model} => #{include}" unless ass
193
177
 
194
- def make_conditions(search_filter)
195
- includes = []
196
- if search_filter
197
- condition_strings = []
198
- condition_values = []
199
-
200
- filter_texts = []
201
- search_filter.each do |field, values|
202
- dimension_def = @dimension_defs[field]
203
- raise "Unknown filter field: #{field.inspect}" if dimension_def.nil?
204
- values = [*values]
205
- if dimension_def[:interval]
206
- if dimension_def[:condition_values_method]
207
- condition_values += dimension_def[:condition_values_method].call(values)
178
+ model_table = model.to_s.pluralize
179
+ include_table = ass.table_name
180
+ include_alias = include.to_s.pluralize
181
+ case ass.macro
182
+ when :belongs_to
183
+ "LEFT JOIN #{include_table} #{include_alias} ON #{include_alias}.id = #{model_table}.#{include}_id"
184
+ when :has_one, :has_many
185
+ fk_col = ass.options[:foreign_key] || "#{model}_id"
186
+ sql = "LEFT JOIN #{include_table} #{include_alias} ON #{include_alias}.#{fk_col} = #{model_table}.id"
187
+ if (ass_order = ass.options[:order].try(:to_s))
188
+ ass_order.sub!(/ DESC\s*$/i, '')
189
+ ass_order_prefixed = ass_order.dup
190
+ ActiveRecord::Base.connection.columns(include_table).map(&:name).each do |cname|
191
+ ass_order_prefixed.gsub!(/\b#{cname}\b/, "#{include_alias}.#{cname}")
192
+ end
193
+ sql << " AND #{ass_order_prefixed} = (SELECT MIN(#{ass_order}) FROM #{include_table} t2 WHERE t2.#{fk_col} = #{model_table}.id #{
194
+ if ass.klass.paranoid?
195
+ 'AND t2.deleted_at IS NULL'
196
+ end})"
197
+ end
198
+ sql
208
199
  else
209
- condition_values += [*values]
200
+ raise "Unknown association type: #{ass.macro}"
210
201
  end
211
- filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(values) : "from #{values[0]} to #{values[1] || values[0]}"}"
212
- includes << dimension_def[:includes] if dimension_def[:includes]
213
- condition_strings << dimension_def[:condition_string]
202
+ when String
203
+ include
204
+ when nil
205
+ ''
214
206
  else
215
- condition_strings << values.map do |value|
216
- if dimension_def[:condition_values_method]
217
- condition_values += dimension_def[:condition_values_method].call(value)
218
- else
219
- condition_values << value
220
- end
221
- filter_texts << "#{dimension_def[:pretty_name]} #{dimension_def[:label_method] ? dimension_def[:label_method].call(value) : value}"
222
- includes << dimension_def[:includes] if dimension_def[:includes]
223
- dimension_def[:condition_string]
224
- end.join(' OR ')
207
+ raise "Unknown join class: #{include.inspect}"
225
208
  end
226
209
  end
227
- filter_text = filter_texts.join(' and ')
228
- conditions = [condition_strings.map { |c| "(#{c})" }.join(" AND "), *condition_values]
210
+ end
211
+
212
+ def initialize
213
+ super()
214
+ @fields = @@fields
215
+ @default_fields = @@default_fields
216
+ @default_select_value = SimpleDrilldown::Search::SelectValue::COUNT
217
+ @target_class = @@target_class
218
+ @select = @@select
219
+ @@base_condition = '1 = 1' unless defined?(@@base_condition)
220
+ @base_condition = @@base_condition
221
+ @@base_includes = [] unless defined?(@@base_includes)
222
+ @base_includes = @@base_includes
223
+ @base_group = defined?(@@base_group) ? @@base_group : []
224
+ @@list_includes = [] unless defined?(@@list_includes)
225
+ @list_includes = @@list_includes
226
+ @list_order = @@list_order
227
+ @dimension_defs = @@dimension_defs
228
+ @summary_fields = []
229
+ end
230
+
231
+ # ?dimension[0]=supplier&dimension[1]=transaction_type&
232
+ # filter[year]=2009&filter[supplier][0]=Shell&filter[supplier][1]=Statoil
233
+ def index(do_render = true)
234
+ @search = Search.new(params[:search]&.to_unsafe_h, @default_fields, @default_select_value)
235
+
236
+ @transaction_fields = (@search.fields + (@fields.keys.map(&:to_s) - @search.fields))
237
+ @transaction_fields_map = @fields
238
+
239
+ select = @select.dup
240
+ includes = @base_includes.dup
241
+
242
+ @dimensions = []
243
+ select << ", 'All' as value0"
244
+ @dimensions += @search.dimensions.map do |dn|
245
+ if @dimension_defs[dn].nil?
246
+ raise "Unknown distribution field: #{@search.dimensions.inspect}"
247
+ end
248
+
249
+ @dimension_defs[dn]
250
+ end
251
+ @dimensions.each_with_index do |d, i|
252
+ select << ", #{d[:select_expression]} as value#{i + 1}"
253
+ includes << d[:includes] if d[:includes]
254
+ end
255
+
256
+ conditions, @filter_text, filter_includes = self.class.make_conditions(@search.filter)
257
+ includes += filter_includes
229
258
  includes.uniq!
230
- else
231
- filter_text = nil
232
- conditions = nil
259
+ if @search.order_by_value && @dimensions.size <= 1
260
+ order = 'count DESC'
261
+ else
262
+ order = @dimensions.map { |d| d[:select_expression] }.join(', ')
263
+ order = nil if order.empty?
264
+ end
265
+ group = (@base_group + @dimensions.map { |d| d[:select_expression] }).join(', ')
266
+ group = nil if group.empty?
267
+
268
+ rows = @target_class.unscoped.where(@base_condition).select(select).where(conditions)
269
+ .joins(self.class.make_join([], @target_class.name.underscore.to_sym, includes))
270
+ .group(group)
271
+ .order(order).all.to_a
272
+
273
+ if rows.empty?
274
+ @result = { value: 'All', count: 0, row_count: 0, nodes: 0, rows: [] }
275
+ else
276
+ if do_render && @search.list && rows.inject(0) { |sum, r| sum + r[:count].to_i } > LIST_LIMIT
277
+ @search.list = false
278
+ flash[:notice] = "More than #{LIST_LIMIT} records. List disabled."
279
+ end
280
+ @result = result_from_rows(rows, 0, 0, ['All'])
281
+ end
282
+
283
+ remove_duplicates(@result) unless @base_group.empty?
284
+
285
+ @remaining_dimensions = @dimension_defs.dup.delete_if do |dim_name, _dimension|
286
+ (@search.filter[dim_name] && @search.filter[dim_name].size == 1) ||
287
+ (@dimensions.any? { |d| d[:url_param_name] == dim_name })
288
+ end
289
+
290
+ populate_list(conditions, includes, @result, []) if @search.list
291
+ render template: '/drilldown/index' if do_render
233
292
  end
234
- return conditions, filter_text, includes
235
- end
236
293
 
237
- def legal_values_for(field, preserve_filter = false)
238
- lambda do |search|
239
- my_filter = search.filter.dup
240
- my_filter.delete(field.to_s) unless preserve_filter
241
- conditions, t, includes = make_conditions(my_filter)
242
- dimension = @dimension_defs[field.to_s]
243
- if dimension[:includes]
244
- if dimension[:includes].is_a?(Array)
245
- includes += dimension[:includes]
246
- else
247
- includes << dimension[:includes]
294
+ def remove_duplicates(result)
295
+ rows = result[:rows]
296
+ return 0 unless rows
297
+
298
+ removed_rows = 0
299
+ prev_row = nil
300
+ rows.each do |r|
301
+ if prev_row
302
+ if prev_row[:value] == r[:value]
303
+ prev_row[:count] += r[:count]
304
+ prev_row[:row_count] = [prev_row[:row_count], r[:row_count]].max
305
+ prev_row[:nodes] = [prev_row[:nodes], r[:nodes]].max
306
+ prev_row[:rows] += r[:rows] if prev_row[:rows] || r[:rows]
307
+ r[:value] = nil
308
+ removed_rows += r[:nodes]
309
+ end
248
310
  end
249
- includes.uniq!
311
+ prev_row = r unless r[:value].nil?
312
+ end
313
+ rows.delete_if { |r| r[:value].nil? }
314
+ rows.each do |r|
315
+ removed_child_rows = remove_duplicates(r)
316
+ removed_rows += removed_child_rows
250
317
  end
251
- rows = @target_class.find(
252
- :all,
253
- :select => "DISTINCT(#{dimension[:select_expression]}) AS value",
254
- :conditions => conditions,
255
- :joins => make_join(@target_class.name.underscore.to_sym, includes),
256
- :order => 'value'
257
- )
258
- if search.filter[field.to_s]
259
- search.filter[field.to_s].each do |selected_value|
260
- unless rows.find { |r| r[:value] == selected_value }
261
- rows << {:value => selected_value}
318
+ result[:row_count] -= removed_rows
319
+ result[:nodes] -= removed_rows
320
+ removed_rows
321
+ end
322
+
323
+ # Empty summary rows are needed to plot zero points in the charts
324
+ def add_zero_results(result_rows, dimension)
325
+ legal_values = self.class.legal_values_for(@dimensions[dimension][:url_param_name], true).call(@search).map { |lv| lv[1] }
326
+ legal_values.reverse! if @dimensions[dimension][:reverse]
327
+ current_values = result_rows.map { |r| r[:value] }.compact
328
+ empty_values = legal_values - current_values
329
+
330
+ unless empty_values.empty?
331
+ empty_values.each do |v|
332
+ sub_result = {
333
+ value: v,
334
+ count: 0,
335
+ row_count: 0,
336
+ nodes: 0
337
+ }
338
+ if dimension < @dimensions.size - 1
339
+ sub_result[:rows] = add_zero_results([], dimension + 1)
262
340
  end
341
+ result_rows << sub_result
263
342
  end
264
- rows.sort_by { |r| r[:value] }
343
+ result_rows = result_rows.sort_by { |r| legal_values.index(r[:value]) }
265
344
  end
266
- values = rows.map { |r| [dimension[:label_method] && dimension[:label_method].call(r[:value]) || r[:value], r[:value]] }
267
- values
345
+ result_rows
268
346
  end
269
- end
270
347
 
271
- def dimension(name, select_expression, options = {})
272
- includes = options.delete(:includes)
273
- interval = options.delete(:interval)
274
- label_method = options.delete(:label_method)
275
- legal_values = options.delete(:legal_values) || legal_values_for(name)
276
-
277
- raise "Unknown options: #{options.keys.inspect}" unless options.empty?
278
-
279
- @dimension_defs[name.to_s] = {
280
- :select_expression => select_expression,
281
- :condition_string => interval ? "#{select_expression} BETWEEN ? AND ?" : "(#{select_expression}) = ?",
282
- :condition_values_method => interval ? lambda { |values| [values[0], values[1] || values[0]] } : nil,
283
- :pretty_name => t(name),
284
- :url_param_name => name.to_s,
285
- :legal_values => legal_values,
286
- :label_method => label_method,
287
- :includes => includes,
288
- :interval => interval,
289
- }
290
- end
348
+ def result_from_rows(rows, row_index, dimension, previous_values)
349
+ row = rows[row_index]
350
+ return nil if row.nil?
291
351
 
292
- def get_transactions(tree)
293
- return tree[:transactions] if tree[:transactions]
294
- tree[:rows].map { |r| get_transactions(r) }.flatten
295
- end
352
+ values = (0..dimension).to_a.map { |i| row["value#{i}"] }
353
+ return nil if values != previous_values
296
354
 
297
- def make_join(model, include)
298
- case include
299
- when Array
300
- include.map { |i| make_join(model, i) }.join(' ')
301
- when Hash
302
- sql = ''
303
- include.each do |parent, child|
304
- parent_table = parent.to_s.pluralize
305
- sql << make_join(model, parent) + ' '
306
- sql << make_join(parent, child)
355
+ if dimension == @dimensions.size
356
+ return {
357
+ value: values[-1],
358
+ count: row[:count].to_i,
359
+ row_count: 1,
360
+ nodes: @search.list ? 2 : 1
361
+ }
362
+ end
363
+
364
+ result_rows = []
365
+ loop do
366
+ sub_result = result_from_rows(rows, row_index, dimension + 1, values + [rows[row_index]["value#{dimension + 1}"]])
367
+ break if sub_result.nil?
368
+
369
+ result_rows << sub_result
370
+ row_index += sub_result[:row_count]
371
+ break if rows[row_index].nil?
307
372
  end
308
- sql
309
- when Symbol
310
- ass = model.to_s.camelize.constantize.reflect_on_association include
311
- include_table = ass.table_name
312
- "LEFT JOIN #{include_table} ON #{include_table}.id = #{model.to_s.pluralize}.#{include}_id"
313
- else
314
- raise 'Unknown join class: #{include.inspect}'
373
+
374
+ result_rows = add_zero_results(result_rows, dimension)
375
+
376
+ {
377
+ value: values[-1],
378
+ count: result_rows.inject(0) { |t, r| t + r[:count].to_i },
379
+ row_count: result_rows.inject(0) { |t, r| t + r[:row_count] },
380
+ nodes: result_rows.inject(0) { |t, r| t + r[:nodes] } + 1,
381
+ rows: result_rows
382
+ }
315
383
  end
316
- end
317
384
 
385
+ def html_export
386
+ index(false)
387
+ render template: '/drilldown/html_export', layout: '../drilldown/print'
388
+ end
389
+
390
+ def excel_export
391
+ index(false)
392
+ headers['Content-Type'] = 'application/vnd.ms-excel'
393
+ headers['Content-Disposition'] = 'attachment; filename="elections.xls"'
394
+ headers['Cache-Control'] = ''
395
+ render template: '/drilldown/excel_export', layout: false
396
+ end
397
+
398
+ private
399
+
400
+ def populate_list(conditions, includes, result, values)
401
+ if result[:rows]
402
+ result[:rows].each do |r|
403
+ populate_list(conditions, includes, r, values + [r[:value]])
404
+ end
405
+ else
406
+ options = { include: includes + @list_includes, order: @list_order }
407
+ @search.fields.each do |field|
408
+ field_def = @transaction_fields_map[field.to_sym]
409
+ raise "Field definition missing for: #{field.inspect}" unless field_def
410
+
411
+ field_includes = field_def[:include]
412
+ if field_includes
413
+ options[:include] += field_includes.is_a?(Array) ? field_includes : [field_includes]
414
+ end
415
+ end
416
+ options[:include].uniq!
417
+
418
+ joins = self.class.make_join([], @target_class.name.underscore.to_sym, options.delete(:include))
419
+ result[:transactions] = @target_class.joins(joins).where(@base_condition).where(list_conditions(conditions, values)).includes(options[:include]).order(options[:order]).all
420
+ end
421
+ end
422
+
423
+ def list_conditions(conditions, values)
424
+ list_conditions_string = conditions[0].dup
425
+ @dimensions.each do |d|
426
+ list_conditions_string << "#{unless list_conditions_string.empty?
427
+ ' AND '
428
+ end}#{d[:select_expression]} = ?"
429
+ end
430
+ [list_conditions_string, *(conditions[1..-1] + values)]
431
+ end
432
+ end
318
433
  end