blazer_xlsx 3.0.5

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 (148) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +442 -0
  3. data/CONTRIBUTING.md +42 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +1093 -0
  6. data/app/assets/fonts/blazer/glyphicons-halflings-regular.eot +0 -0
  7. data/app/assets/fonts/blazer/glyphicons-halflings-regular.svg +288 -0
  8. data/app/assets/fonts/blazer/glyphicons-halflings-regular.ttf +0 -0
  9. data/app/assets/fonts/blazer/glyphicons-halflings-regular.woff +0 -0
  10. data/app/assets/fonts/blazer/glyphicons-halflings-regular.woff2 +0 -0
  11. data/app/assets/images/blazer/favicon.png +0 -0
  12. data/app/assets/javascripts/blazer/Sortable.js +3709 -0
  13. data/app/assets/javascripts/blazer/ace/ace.js +19630 -0
  14. data/app/assets/javascripts/blazer/ace/ext-language_tools.js +1981 -0
  15. data/app/assets/javascripts/blazer/ace/mode-sql.js +215 -0
  16. data/app/assets/javascripts/blazer/ace/snippets/sql.js +16 -0
  17. data/app/assets/javascripts/blazer/ace/snippets/text.js +9 -0
  18. data/app/assets/javascripts/blazer/ace/theme-twilight.js +18 -0
  19. data/app/assets/javascripts/blazer/ace.js +6 -0
  20. data/app/assets/javascripts/blazer/application.js +84 -0
  21. data/app/assets/javascripts/blazer/bootstrap.js +2580 -0
  22. data/app/assets/javascripts/blazer/chart.umd.js +13 -0
  23. data/app/assets/javascripts/blazer/chartjs-adapter-date-fns.bundle.js +6322 -0
  24. data/app/assets/javascripts/blazer/chartkick.js +2570 -0
  25. data/app/assets/javascripts/blazer/daterangepicker.js +1578 -0
  26. data/app/assets/javascripts/blazer/fuzzysearch.js +24 -0
  27. data/app/assets/javascripts/blazer/highlight.min.js +466 -0
  28. data/app/assets/javascripts/blazer/jquery.js +10872 -0
  29. data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +325 -0
  30. data/app/assets/javascripts/blazer/mapkick.bundle.js +1029 -0
  31. data/app/assets/javascripts/blazer/moment-timezone-with-data.js +1548 -0
  32. data/app/assets/javascripts/blazer/moment.js +5685 -0
  33. data/app/assets/javascripts/blazer/queries.js +130 -0
  34. data/app/assets/javascripts/blazer/rails-ujs.js +746 -0
  35. data/app/assets/javascripts/blazer/routes.js +26 -0
  36. data/app/assets/javascripts/blazer/selectize.js +3891 -0
  37. data/app/assets/javascripts/blazer/stupidtable-custom-settings.js +13 -0
  38. data/app/assets/javascripts/blazer/stupidtable.js +281 -0
  39. data/app/assets/javascripts/blazer/vue.global.prod.js +1 -0
  40. data/app/assets/stylesheets/blazer/application.css +243 -0
  41. data/app/assets/stylesheets/blazer/bootstrap-propshaft.css +10 -0
  42. data/app/assets/stylesheets/blazer/bootstrap-sprockets.css.erb +10 -0
  43. data/app/assets/stylesheets/blazer/bootstrap.css +6828 -0
  44. data/app/assets/stylesheets/blazer/daterangepicker.css +410 -0
  45. data/app/assets/stylesheets/blazer/github.css +125 -0
  46. data/app/assets/stylesheets/blazer/selectize.css +403 -0
  47. data/app/controllers/blazer/base_controller.rb +135 -0
  48. data/app/controllers/blazer/checks_controller.rb +56 -0
  49. data/app/controllers/blazer/dashboards_controller.rb +99 -0
  50. data/app/controllers/blazer/queries_controller.rb +472 -0
  51. data/app/controllers/blazer/uploads_controller.rb +147 -0
  52. data/app/helpers/blazer/base_helper.rb +39 -0
  53. data/app/models/blazer/audit.rb +6 -0
  54. data/app/models/blazer/check.rb +104 -0
  55. data/app/models/blazer/connection.rb +5 -0
  56. data/app/models/blazer/dashboard.rb +17 -0
  57. data/app/models/blazer/dashboard_query.rb +9 -0
  58. data/app/models/blazer/query.rb +42 -0
  59. data/app/models/blazer/record.rb +5 -0
  60. data/app/models/blazer/upload.rb +11 -0
  61. data/app/models/blazer/uploads_connection.rb +7 -0
  62. data/app/views/blazer/_nav.html.erb +18 -0
  63. data/app/views/blazer/_variables.html.erb +127 -0
  64. data/app/views/blazer/check_mailer/failing_checks.html.erb +7 -0
  65. data/app/views/blazer/check_mailer/state_change.html.erb +48 -0
  66. data/app/views/blazer/checks/_form.html.erb +79 -0
  67. data/app/views/blazer/checks/edit.html.erb +3 -0
  68. data/app/views/blazer/checks/index.html.erb +72 -0
  69. data/app/views/blazer/checks/new.html.erb +3 -0
  70. data/app/views/blazer/dashboards/_form.html.erb +82 -0
  71. data/app/views/blazer/dashboards/edit.html.erb +3 -0
  72. data/app/views/blazer/dashboards/new.html.erb +3 -0
  73. data/app/views/blazer/dashboards/show.html.erb +53 -0
  74. data/app/views/blazer/queries/_caching.html.erb +16 -0
  75. data/app/views/blazer/queries/_cohorts.html.erb +48 -0
  76. data/app/views/blazer/queries/_form.html.erb +255 -0
  77. data/app/views/blazer/queries/docs.html.erb +147 -0
  78. data/app/views/blazer/queries/edit.html.erb +2 -0
  79. data/app/views/blazer/queries/home.html.erb +169 -0
  80. data/app/views/blazer/queries/new.html.erb +2 -0
  81. data/app/views/blazer/queries/run.html.erb +183 -0
  82. data/app/views/blazer/queries/schema.html.erb +55 -0
  83. data/app/views/blazer/queries/show.html.erb +72 -0
  84. data/app/views/blazer/uploads/_form.html.erb +27 -0
  85. data/app/views/blazer/uploads/edit.html.erb +3 -0
  86. data/app/views/blazer/uploads/index.html.erb +55 -0
  87. data/app/views/blazer/uploads/new.html.erb +3 -0
  88. data/app/views/layouts/blazer/application.html.erb +25 -0
  89. data/config/routes.rb +25 -0
  90. data/lib/blazer/adapters/athena_adapter.rb +182 -0
  91. data/lib/blazer/adapters/base_adapter.rb +76 -0
  92. data/lib/blazer/adapters/bigquery_adapter.rb +79 -0
  93. data/lib/blazer/adapters/cassandra_adapter.rb +70 -0
  94. data/lib/blazer/adapters/drill_adapter.rb +38 -0
  95. data/lib/blazer/adapters/druid_adapter.rb +102 -0
  96. data/lib/blazer/adapters/elasticsearch_adapter.rb +61 -0
  97. data/lib/blazer/adapters/hive_adapter.rb +55 -0
  98. data/lib/blazer/adapters/ignite_adapter.rb +64 -0
  99. data/lib/blazer/adapters/influxdb_adapter.rb +57 -0
  100. data/lib/blazer/adapters/neo4j_adapter.rb +62 -0
  101. data/lib/blazer/adapters/opensearch_adapter.rb +52 -0
  102. data/lib/blazer/adapters/presto_adapter.rb +54 -0
  103. data/lib/blazer/adapters/salesforce_adapter.rb +50 -0
  104. data/lib/blazer/adapters/snowflake_adapter.rb +82 -0
  105. data/lib/blazer/adapters/soda_adapter.rb +105 -0
  106. data/lib/blazer/adapters/spark_adapter.rb +14 -0
  107. data/lib/blazer/adapters/sql_adapter.rb +353 -0
  108. data/lib/blazer/adapters.rb +17 -0
  109. data/lib/blazer/anomaly_detectors.rb +22 -0
  110. data/lib/blazer/check_mailer.rb +27 -0
  111. data/lib/blazer/data_source.rb +266 -0
  112. data/lib/blazer/engine.rb +42 -0
  113. data/lib/blazer/forecasters.rb +7 -0
  114. data/lib/blazer/result.rb +178 -0
  115. data/lib/blazer/result_cache.rb +71 -0
  116. data/lib/blazer/run_statement.rb +45 -0
  117. data/lib/blazer/run_statement_job.rb +20 -0
  118. data/lib/blazer/slack_notifier.rb +94 -0
  119. data/lib/blazer/statement.rb +77 -0
  120. data/lib/blazer/version.rb +3 -0
  121. data/lib/blazer.rb +282 -0
  122. data/lib/generators/blazer/install_generator.rb +22 -0
  123. data/lib/generators/blazer/templates/config.yml.tt +79 -0
  124. data/lib/generators/blazer/templates/install.rb.tt +47 -0
  125. data/lib/generators/blazer/templates/uploads.rb.tt +10 -0
  126. data/lib/generators/blazer/uploads_generator.rb +18 -0
  127. data/lib/tasks/blazer.rake +20 -0
  128. data/licenses/LICENSE-ace.txt +24 -0
  129. data/licenses/LICENSE-bootstrap.txt +21 -0
  130. data/licenses/LICENSE-chart.js.txt +9 -0
  131. data/licenses/LICENSE-chartjs-adapter-date-fns.txt +9 -0
  132. data/licenses/LICENSE-chartkick.js.txt +22 -0
  133. data/licenses/LICENSE-date-fns.txt +21 -0
  134. data/licenses/LICENSE-daterangepicker.txt +21 -0
  135. data/licenses/LICENSE-fuzzysearch.txt +20 -0
  136. data/licenses/LICENSE-highlight.js.txt +29 -0
  137. data/licenses/LICENSE-jquery.txt +20 -0
  138. data/licenses/LICENSE-kurkle-color.txt +9 -0
  139. data/licenses/LICENSE-mapkick-bundle.txt +1029 -0
  140. data/licenses/LICENSE-moment-timezone.txt +20 -0
  141. data/licenses/LICENSE-moment.txt +22 -0
  142. data/licenses/LICENSE-rails-ujs.txt +20 -0
  143. data/licenses/LICENSE-selectize.txt +202 -0
  144. data/licenses/LICENSE-sortable.txt +21 -0
  145. data/licenses/LICENSE-stickytableheaders.txt +20 -0
  146. data/licenses/LICENSE-stupidtable.txt +19 -0
  147. data/licenses/LICENSE-vue.txt +21 -0
  148. metadata +271 -0
@@ -0,0 +1,472 @@
1
+ module Blazer
2
+ class QueriesController < BaseController
3
+ before_action :set_query, only: [:show, :edit, :update, :destroy, :refresh]
4
+ before_action :set_data_source, only: [:tables, :docs, :schema, :cancel]
5
+
6
+ def home
7
+ set_queries(1000)
8
+
9
+ if params[:filter]
10
+ @dashboards = [] # TODO show my dashboards
11
+ else
12
+ @dashboards = Blazer::Dashboard.order(:name)
13
+ @dashboards = @dashboards.includes(:creator) if Blazer.user_class
14
+ end
15
+
16
+ @dashboards =
17
+ @dashboards.map do |d|
18
+ {
19
+ id: d.id,
20
+ name: d.name,
21
+ creator: blazer_user && d.try(:creator) == blazer_user ? "You" : d.try(:creator).try(Blazer.user_name),
22
+ to_param: d.to_param,
23
+ dashboard: true
24
+ }
25
+ end
26
+ end
27
+
28
+ def index
29
+ set_queries
30
+ render json: @queries
31
+ end
32
+
33
+ def new
34
+ @query = Blazer::Query.new(
35
+ data_source: params[:data_source],
36
+ name: params[:name]
37
+ )
38
+ if params[:fork_query_id]
39
+ @query.statement ||= Blazer::Query.find(params[:fork_query_id]).try(:statement)
40
+ end
41
+ if params[:upload_id]
42
+ upload = Blazer::Upload.find(params[:upload_id])
43
+ upload_settings = Blazer.settings["uploads"]
44
+ @query.data_source ||= upload_settings["data_source"]
45
+ @query.statement ||= "SELECT * FROM #{upload.table_name} LIMIT 10"
46
+ end
47
+ end
48
+
49
+ def create
50
+ @query = Blazer::Query.new(query_params)
51
+ @query.creator = blazer_user if @query.respond_to?(:creator)
52
+ @query.status = "active" if @query.respond_to?(:status)
53
+
54
+ if @query.save
55
+ redirect_to query_path(@query, params: variable_params(@query))
56
+ else
57
+ render_errors @query
58
+ end
59
+ end
60
+
61
+ def show
62
+ @statement = @query.statement_object
63
+ @success = process_vars(@statement)
64
+
65
+ @smart_vars = {}
66
+ @sql_errors = []
67
+ @bind_vars.each do |var|
68
+ smart_var, error = parse_smart_variables(var, @statement.data_source)
69
+ @smart_vars[var] = smart_var if smart_var
70
+ @sql_errors << error if error
71
+ end
72
+
73
+ @query.update!(status: "active") if @query.respond_to?(:status) && @query.status.in?(["archived", nil])
74
+
75
+ add_cohort_analysis_vars if @query.cohort_analysis?
76
+
77
+ if @success
78
+ @run_data = {statement: @query.statement, query_id: @query.id, data_source: @query.data_source, variables: variable_params(@query)}
79
+ @run_data[:forecast] = "t" if params[:forecast]
80
+ @run_data[:cohort_period] = params[:cohort_period] if params[:cohort_period]
81
+ end
82
+ end
83
+
84
+ def edit
85
+ end
86
+
87
+ def run
88
+ @query = Query.find_by(id: params[:query_id]) if params[:query_id]
89
+
90
+ # use query data source when present
91
+ data_source = @query.data_source if @query && @query.data_source
92
+ data_source ||= params[:data_source]
93
+ @data_source = Blazer.data_sources[data_source]
94
+
95
+ @statement = Blazer::Statement.new(params[:statement], @data_source)
96
+ # before process_vars
97
+ @cohort_analysis = @statement.cohort_analysis?
98
+
99
+ # fallback for now for users with open tabs
100
+ # TODO remove fallback in future version
101
+ @var_params = request.request_parameters["variables"] || request.request_parameters
102
+ @success = process_vars(@statement, @var_params)
103
+ @only_chart = params[:only_chart]
104
+ @run_id = blazer_params[:run_id]
105
+
106
+ run_cohort_analysis if @cohort_analysis
107
+
108
+ query_running = !@run_id.nil?
109
+
110
+ if query_running
111
+ @timestamp = blazer_params[:timestamp].to_i
112
+
113
+ @result = @data_source.run_results(@run_id)
114
+ @success = !@result.nil?
115
+
116
+ if @success
117
+ @data_source.delete_results(@run_id)
118
+ @columns = @result.columns
119
+ @rows = @result.rows
120
+ @error = @result.error
121
+ @just_cached = !@result.error && @result.cached_at.present?
122
+ @cached_at = nil
123
+ params[:data_source] = nil
124
+ render_run
125
+ elsif Time.now > Time.at(@timestamp + (@data_source.timeout || 600).to_i + 5)
126
+ # query lost
127
+ Rails.logger.info "[blazer lost query] #{@run_id}"
128
+ @error = "We lost your query :("
129
+ @rows = []
130
+ @columns = []
131
+ render_run
132
+ else
133
+ continue_run
134
+ end
135
+ elsif @success
136
+ @run_id = blazer_run_id
137
+
138
+ async = Blazer.async
139
+
140
+ options = {user: blazer_user, query: @query, refresh_cache: params[:check], run_id: @run_id, async: async}
141
+ if async && request.format.symbol != :csv
142
+ Blazer::RunStatementJob.perform_later(@data_source.id, @statement.statement, options.merge(values: @statement.values))
143
+ wait_start = Blazer.monotonic_time
144
+ loop do
145
+ sleep(0.1)
146
+ @result = @data_source.run_results(@run_id)
147
+ break if @result || Blazer.monotonic_time - wait_start > 3
148
+ end
149
+ else
150
+ @result = Blazer::RunStatement.new.perform(@statement, options)
151
+ end
152
+
153
+ if @result
154
+ @data_source.delete_results(@run_id) if @run_id && async
155
+
156
+ @columns = @result.columns
157
+ @rows = @result.rows
158
+ @error = @result.error
159
+ @cached_at = @result.cached_at
160
+ @just_cached = @result.just_cached
161
+
162
+ @forecast = @query && @result.forecastable? && params[:forecast]
163
+ if @forecast
164
+ @result.forecast
165
+ @forecast_error = @result.forecast_error
166
+ @forecast = @forecast_error.nil?
167
+ end
168
+
169
+ render_run
170
+ else
171
+ @timestamp = Time.now.to_i
172
+ continue_run
173
+ end
174
+ else
175
+ render layout: false
176
+ end
177
+ end
178
+
179
+ def refresh
180
+ refresh_query(@query)
181
+ redirect_to query_path(@query, params: variable_params(@query))
182
+ end
183
+
184
+ def update
185
+ if params[:commit] == "Fork"
186
+ @query = Blazer::Query.new
187
+ @query.creator = blazer_user if @query.respond_to?(:creator)
188
+ end
189
+ @query.status = "active" if @query.respond_to?(:status)
190
+ unless @query.editable?(blazer_user)
191
+ @query.errors.add(:base, "Sorry, permission denied")
192
+ end
193
+ if @query.errors.empty? && @query.update(query_params)
194
+ redirect_to query_path(@query, params: variable_params(@query))
195
+ else
196
+ render_errors @query
197
+ end
198
+ end
199
+
200
+ def destroy
201
+ @query.destroy if @query.editable?(blazer_user)
202
+ redirect_to root_path
203
+ end
204
+
205
+ def tables
206
+ render json: @data_source.tables
207
+ end
208
+
209
+ def docs
210
+ @smart_variables = @data_source.smart_variables
211
+ @linked_columns = @data_source.linked_columns
212
+ @smart_columns = @data_source.smart_columns
213
+ end
214
+
215
+ def schema
216
+ @schema = @data_source.schema
217
+ end
218
+
219
+ def cancel
220
+ @data_source.cancel(blazer_run_id)
221
+ head :ok
222
+ end
223
+
224
+ private
225
+
226
+ def set_data_source
227
+ @data_source = Blazer.data_sources[params[:data_source]]
228
+ end
229
+
230
+ def continue_run
231
+ render json: {run_id: @run_id, timestamp: @timestamp}, status: :accepted
232
+ end
233
+
234
+ def render_run
235
+ @checks = @query ? @query.checks.order(:id) : []
236
+
237
+ @first_row = @rows.first || []
238
+ @column_types = []
239
+ if @rows.any?
240
+ @columns.each_with_index do |_, i|
241
+ @column_types << (
242
+ case @first_row[i]
243
+ when Integer
244
+ "int"
245
+ when Float, BigDecimal
246
+ "float"
247
+ else
248
+ "string-ins"
249
+ end
250
+ )
251
+ end
252
+ end
253
+
254
+ @min_width_types = @columns.each_with_index.select { |c, i| @first_row[i].is_a?(Time) || @first_row[i].is_a?(String) || @data_source.smart_columns[c] }.map(&:last)
255
+
256
+ @smart_values = @result.smart_values if @result
257
+
258
+ @linked_columns = @data_source.linked_columns
259
+
260
+ @markers = []
261
+ @geojson = []
262
+ set_map_data if Blazer.maps?
263
+
264
+ render_cohort_analysis if @cohort_analysis && !@error
265
+
266
+ respond_to do |format|
267
+ format.html do
268
+ render layout: false
269
+ end
270
+ format.csv do
271
+ # not ideal, but useful for testing
272
+ raise Error, @error if @error && Rails.env.test?
273
+
274
+ data = csv_data(@columns, @rows, @data_source)
275
+ filename = "#{@query.try(:name).try(:parameterize).presence || 'query'}.xlsx"
276
+ send_data data, type: "xlsx", disposition: "attachment", filename: filename
277
+ end
278
+ end
279
+ end
280
+
281
+ def set_map_data
282
+ [["latitude", "longitude"], ["lat", "lon"], ["lat", "lng"]].each do |keys|
283
+ lat_index = @columns.index(keys.first)
284
+ lon_index = @columns.index(keys.last)
285
+ if lat_index && lon_index
286
+ @markers =
287
+ @rows.select do |r|
288
+ r[lat_index] && r[lon_index]
289
+ end.map do |r|
290
+ {
291
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == lat_index || i == lon_index }),
292
+ latitude: r[lat_index],
293
+ longitude: r[lon_index]
294
+ }
295
+ end
296
+
297
+ return if @markers.any?
298
+ end
299
+ end
300
+
301
+ geo_index = @columns.index("geojson")
302
+ if geo_index
303
+ @geojson =
304
+ @rows.filter_map do |r|
305
+ if r[geo_index].is_a?(String) && (geometry = (JSON.parse(r[geo_index]) rescue nil)) && geometry.is_a?(Hash)
306
+ {
307
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == geo_index }),
308
+ geometry: geometry
309
+ }
310
+ end
311
+ end
312
+ end
313
+ end
314
+
315
+ def map_tooltip(r)
316
+ r.map { |v, i| "<strong>#{ERB::Util.html_escape(@columns[i])}:</strong> #{ERB::Util.html_escape(v)}" }.join("<br>").truncate(140, separator: " ")
317
+ end
318
+
319
+ def set_queries(limit = nil)
320
+ @queries = Blazer::Query.named.select(:id, :name, :creator_id, :statement)
321
+ @queries = @queries.includes(:creator) if Blazer.user_class
322
+
323
+ if blazer_user && params[:filter] == "mine"
324
+ @queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
325
+ elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
326
+ @queries = queries_by_ids(Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq)
327
+ else
328
+ @queries = @queries.limit(limit) if limit
329
+ @queries = @queries.active.order(:name)
330
+ end
331
+ @queries = @queries.to_a
332
+
333
+ @more = limit && @queries.size >= limit
334
+
335
+ @queries = @queries.select { |q| !q.name.to_s.start_with?("#") || q.try(:creator).try(:id) == blazer_user.try(:id) }
336
+
337
+ @queries =
338
+ @queries.map do |q|
339
+ {
340
+ id: q.id,
341
+ name: q.name,
342
+ creator: blazer_user && q.try(:creator) == blazer_user ? "You" : q.try(:creator).try(Blazer.user_name),
343
+ vars: q.variables.join(", "),
344
+ to_param: q.to_param
345
+ }
346
+ end
347
+ end
348
+
349
+ def queries_by_ids(favorite_query_ids)
350
+ queries = Blazer::Query.active.named.where(id: favorite_query_ids)
351
+ queries = queries.includes(:creator) if Blazer.user_class
352
+ queries = queries.index_by(&:id)
353
+ favorite_query_ids.map { |query_id| queries[query_id] }.compact
354
+ end
355
+
356
+ def set_query
357
+ @query = Blazer::Query.find(params[:id].to_s.split("-").first)
358
+ end
359
+
360
+ def render_forbidden
361
+ render plain: "Access denied", status: :forbidden
362
+ end
363
+
364
+ def query_params
365
+ params.require(:query).permit(:name, :description, :statement, :data_source)
366
+ end
367
+
368
+ def blazer_params
369
+ params[:blazer] || {}
370
+ end
371
+
372
+ def csv_data(columns, rows, data_source)
373
+ io = StringIO.new
374
+ xlsx = Xlsxtream::Workbook.new(io)
375
+ xlsx.write_worksheet(name: "Sheet1", auto_format: true) do |sheet|
376
+ sheet << columns
377
+ rows.each do |row|
378
+ sheet << row.each_with_index.map { |v, i| v.is_a?(Time) ? blazer_time_value(data_source, columns[i], v) : v }
379
+ end
380
+ end
381
+ xlsx.close
382
+ io.rewind
383
+ io.read
384
+ end
385
+
386
+ def blazer_time_value(data_source, k, v)
387
+ data_source.local_time_suffix.any? { |s| k.ends_with?(s) } ? v.to_s.sub(" UTC", "") : v.in_time_zone(Blazer.time_zone)
388
+ end
389
+ helper_method :blazer_time_value
390
+
391
+ def blazer_run_id
392
+ params[:run_id].to_s.gsub(/[^a-z0-9\-]/i, "")
393
+ end
394
+
395
+ def run_cohort_analysis
396
+ unless @statement.data_source.supports_cohort_analysis?
397
+ @cohort_error = "This data source does not support cohort analysis"
398
+ end
399
+
400
+ @show_cohort_rows = !params[:query_id] || @cohort_error
401
+ cohort_analysis_statement(@statement) unless @show_cohort_rows
402
+ end
403
+
404
+ def render_cohort_analysis
405
+ if @show_cohort_rows
406
+ @cohort_analysis = false
407
+
408
+ @row_limit = 1000
409
+
410
+ # check results
411
+ unless @cohort_error
412
+ # check names
413
+ expected_columns = ["user_id", "conversion_time"]
414
+ missing_columns = expected_columns - @result.columns
415
+ if missing_columns.any?
416
+ @cohort_error = "Expected user_id and conversion_time columns"
417
+ end
418
+
419
+ # check types (user_id can be any type)
420
+ unless @cohort_error
421
+ column_types = @result.columns.zip(@result.column_types).to_h
422
+
423
+ if !column_types["cohort_time"].in?(["time", nil])
424
+ @cohort_error = "cohort_time must be time column"
425
+ elsif !column_types["conversion_time"].in?(["time", nil])
426
+ @cohort_error = "conversion_time must be time column"
427
+ end
428
+ end
429
+ end
430
+ else
431
+ @today = Blazer.time_zone.today
432
+ @min_cohort_date, @max_cohort_date = @result.rows.map { |r| r[0] }.minmax
433
+ @buckets = {}
434
+ @rows.each do |r|
435
+ @buckets[[r[0], r[1]]] = r[2]
436
+ end
437
+
438
+ @cohort_dates = []
439
+ current_date = @max_cohort_date
440
+ while current_date && current_date >= @min_cohort_date
441
+ @cohort_dates << current_date
442
+ current_date =
443
+ case @cohort_period
444
+ when "day"
445
+ current_date - 1
446
+ when "week"
447
+ current_date - 7
448
+ else
449
+ current_date.prev_month
450
+ end
451
+ end
452
+
453
+ num_cols = @cohort_dates.size
454
+ @columns = ["Cohort", "Users"] + num_cols.times.map { |i| "#{@conversion_period.titleize} #{i + 1}" }
455
+ rows = []
456
+ date_format = @cohort_period == "month" ? "%b %Y" : "%b %-e, %Y"
457
+ @cohort_dates.each do |date|
458
+ row = [date.strftime(date_format), @buckets[[date, 0]] || 0]
459
+
460
+ num_cols.times do |i|
461
+ if @today >= date + (@cohort_days * i)
462
+ row << (@buckets[[date, i + 1]] || 0)
463
+ end
464
+ end
465
+
466
+ rows << row
467
+ end
468
+ @rows = rows
469
+ end
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,147 @@
1
+ module Blazer
2
+ class UploadsController < BaseController
3
+ before_action :ensure_uploads
4
+ before_action :set_upload, only: [:show, :edit, :update, :destroy]
5
+
6
+ def index
7
+ @uploads = Blazer::Upload.order(:table)
8
+ end
9
+
10
+ def new
11
+ @upload = Blazer::Upload.new
12
+ end
13
+
14
+ def create
15
+ @upload = Blazer::Upload.new(upload_params)
16
+ # use creator_id instead of creator
17
+ # since we setup association without checking if column exists
18
+ @upload.creator = blazer_user if @upload.respond_to?(:creator_id=) && blazer_user
19
+
20
+ success = params.require(:upload).key?(:file)
21
+ if success
22
+ Blazer::Upload.transaction do
23
+ success = @upload.save
24
+ if success
25
+ begin
26
+ update_file(@upload)
27
+ rescue CSV::MalformedCSVError, Blazer::UploadError => e
28
+ @upload.errors.add(:base, e.message)
29
+ success = false
30
+ raise ActiveRecord::Rollback
31
+ end
32
+ end
33
+ end
34
+ else
35
+ @upload.errors.add(:base, "File can't be blank")
36
+ end
37
+
38
+ if success
39
+ redirect_to upload_path(@upload)
40
+ else
41
+ render_errors @upload
42
+ end
43
+ end
44
+
45
+ def show
46
+ redirect_to new_query_path(upload_id: @upload.id)
47
+ end
48
+
49
+ def edit
50
+ end
51
+
52
+ def update
53
+ original_table = @upload.table
54
+ @upload.assign_attributes(upload_params)
55
+
56
+ success = nil
57
+ Blazer::Upload.transaction do
58
+ success = @upload.save
59
+ if success
60
+ if params.require(:upload).key?(:file)
61
+ begin
62
+ update_file(@upload, drop: original_table)
63
+ rescue CSV::MalformedCSVError, Blazer::UploadError => e
64
+ @upload.errors.add(:base, e.message)
65
+ success = false
66
+ raise ActiveRecord::Rollback
67
+ end
68
+ elsif @upload.table != original_table
69
+ Blazer.uploads_connection.execute("ALTER TABLE #{Blazer.uploads_table_name(original_table)} RENAME TO #{Blazer.uploads_connection.quote_table_name(@upload.table)}")
70
+ end
71
+ end
72
+ end
73
+
74
+ if success
75
+ redirect_to upload_path(@upload)
76
+ else
77
+ render_errors @upload
78
+ end
79
+ end
80
+
81
+ def destroy
82
+ Blazer.uploads_connection.execute("DROP TABLE IF EXISTS #{@upload.table_name}")
83
+ @upload.destroy
84
+ redirect_to uploads_path
85
+ end
86
+
87
+ private
88
+
89
+ def update_file(upload, drop: nil)
90
+ file = params.require(:upload).fetch(:file)
91
+ raise Blazer::UploadError, "File is not a CSV" if file.content_type != "text/csv"
92
+ raise Blazer::UploadError, "File is too large (maximum is 100MB)" if file.size > 100.megabytes
93
+
94
+ contents = file.read
95
+ rows = CSV.parse(contents, converters: %i[numeric date date_time])
96
+
97
+ # friendly column names
98
+ columns = rows.shift.map { |v| v.to_s.encode("UTF-8").gsub("%", " pct ").parameterize.gsub("-", "_") }
99
+ duplicate_column = columns.find { |c| columns.count(c) > 1 }
100
+ raise Blazer::UploadError, "Duplicate column name: #{duplicate_column}" if duplicate_column
101
+
102
+ column_types =
103
+ columns.size.times.map do |i|
104
+ values = rows.map { |r| r[i] }.uniq.compact
105
+ if values.all? { |v| v.is_a?(Integer) && v >= -9223372036854775808 && v <= 9223372036854775807 }
106
+ "bigint"
107
+ elsif values.all? { |v| v.is_a?(Numeric) }
108
+ "decimal"
109
+ elsif values.all? { |v| v.is_a?(DateTime) }
110
+ "timestamptz"
111
+ elsif values.all? { |v| v.is_a?(Date) }
112
+ "date"
113
+ else
114
+ "text"
115
+ end
116
+ end
117
+
118
+ begin
119
+ # maybe SET LOCAL statement_timeout = '30s'
120
+ # maybe regenerate CSV in Ruby to ensure consistent parsing
121
+ Blazer.uploads_connection.transaction do
122
+ Blazer.uploads_connection.execute("DROP TABLE IF EXISTS #{Blazer.uploads_table_name(drop)}") if drop
123
+ Blazer.uploads_connection.execute("CREATE TABLE #{upload.table_name} (#{columns.map.with_index { |c, i| "#{Blazer.uploads_connection.quote_column_name(c)} #{column_types[i]}" }.join(", ")})")
124
+ Blazer.uploads_connection.raw_connection.copy_data("COPY #{upload.table_name} FROM STDIN CSV HEADER") do
125
+ Blazer.uploads_connection.raw_connection.put_copy_data(contents)
126
+ end
127
+ end
128
+ rescue ActiveRecord::StatementInvalid => e
129
+ raise Blazer::UploadError, "Table already exists" if e.message.include?("PG::DuplicateTable")
130
+ raise e
131
+ end
132
+ end
133
+
134
+ def upload_params
135
+ params.require(:upload).except(:file).permit(:table, :description)
136
+ end
137
+
138
+ def set_upload
139
+ @upload = Blazer::Upload.find(params[:id])
140
+ end
141
+
142
+ # routes aren't added, but also check here
143
+ def ensure_uploads
144
+ render plain: "Uploads not enabled" unless Blazer.uploads?
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,39 @@
1
+ module Blazer
2
+ module BaseHelper
3
+ def blazer_title(title = nil)
4
+ if title
5
+ content_for(:title) { title }
6
+ else
7
+ content_for?(:title) ? content_for(:title) : nil
8
+ end
9
+ end
10
+
11
+ BLAZER_URL_REGEX = /\Ahttps?:\/\/[\S]+\z/
12
+ BLAZER_IMAGE_EXT = %w[png jpg jpeg gif]
13
+
14
+ def blazer_format_value(key, value)
15
+ if value.is_a?(Numeric) && !key.to_s.end_with?("id") && !key.to_s.start_with?("id")
16
+ number_with_delimiter(value)
17
+ elsif value.is_a?(String) && value =~ BLAZER_URL_REGEX
18
+ # see if image or link
19
+ if Blazer.images && (key.include?("image") || BLAZER_IMAGE_EXT.include?(value.split(".").last.split("?").first.try(:downcase)))
20
+ link_to value, target: "_blank" do
21
+ image_tag value, referrerpolicy: "no-referrer"
22
+ end
23
+ else
24
+ link_to value, value, target: "_blank"
25
+ end
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ def blazer_js_var(name, value)
32
+ "var #{name} = #{json_escape(value.to_json(root: false))};".html_safe
33
+ end
34
+
35
+ def blazer_series_name(k)
36
+ k.nil? ? "null" : k.to_s
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ module Blazer
2
+ class Audit < Record
3
+ belongs_to :user, optional: true, class_name: Blazer.user_class.to_s
4
+ belongs_to :query, optional: true
5
+ end
6
+ end