finery 3.0.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 (153) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +426 -0
  3. data/CONTRIBUTING.md +49 -0
  4. data/LICENSE.txt +25 -0
  5. data/README.md +1144 -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 +87 -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/chartjs-plugin-annotation.min.js +7 -0
  25. data/app/assets/javascripts/blazer/chartkick.js +2570 -0
  26. data/app/assets/javascripts/blazer/daterangepicker.js +1578 -0
  27. data/app/assets/javascripts/blazer/fuzzysearch.js +24 -0
  28. data/app/assets/javascripts/blazer/highlight.min.js +466 -0
  29. data/app/assets/javascripts/blazer/jquery.js +10872 -0
  30. data/app/assets/javascripts/blazer/jquery.stickytableheaders.js +325 -0
  31. data/app/assets/javascripts/blazer/mapkick.bundle.js +1029 -0
  32. data/app/assets/javascripts/blazer/moment-timezone-with-data.js +1548 -0
  33. data/app/assets/javascripts/blazer/moment.js +5685 -0
  34. data/app/assets/javascripts/blazer/queries.js +130 -0
  35. data/app/assets/javascripts/blazer/rails-ujs.js +746 -0
  36. data/app/assets/javascripts/blazer/routes.js +26 -0
  37. data/app/assets/javascripts/blazer/selectize.js +3891 -0
  38. data/app/assets/javascripts/blazer/stupidtable-custom-settings.js +13 -0
  39. data/app/assets/javascripts/blazer/stupidtable.js +281 -0
  40. data/app/assets/javascripts/blazer/vue.global.prod.js +1 -0
  41. data/app/assets/stylesheets/blazer/application.css +243 -0
  42. data/app/assets/stylesheets/blazer/bootstrap-propshaft.css +10 -0
  43. data/app/assets/stylesheets/blazer/bootstrap-sprockets.css.erb +10 -0
  44. data/app/assets/stylesheets/blazer/bootstrap.css +6828 -0
  45. data/app/assets/stylesheets/blazer/daterangepicker.css +410 -0
  46. data/app/assets/stylesheets/blazer/github.css +125 -0
  47. data/app/assets/stylesheets/blazer/selectize.css +403 -0
  48. data/app/controllers/blazer/base_controller.rb +133 -0
  49. data/app/controllers/blazer/checks_controller.rb +56 -0
  50. data/app/controllers/blazer/dashboards_controller.rb +99 -0
  51. data/app/controllers/blazer/queries_controller.rb +468 -0
  52. data/app/controllers/blazer/uploads_controller.rb +147 -0
  53. data/app/helpers/blazer/base_helper.rb +83 -0
  54. data/app/models/blazer/audit.rb +6 -0
  55. data/app/models/blazer/check.rb +104 -0
  56. data/app/models/blazer/connection.rb +5 -0
  57. data/app/models/blazer/dashboard.rb +17 -0
  58. data/app/models/blazer/dashboard_query.rb +9 -0
  59. data/app/models/blazer/query.rb +42 -0
  60. data/app/models/blazer/record.rb +5 -0
  61. data/app/models/blazer/upload.rb +11 -0
  62. data/app/models/blazer/uploads_connection.rb +7 -0
  63. data/app/views/blazer/_nav.html.erb +18 -0
  64. data/app/views/blazer/_variables.html.erb +127 -0
  65. data/app/views/blazer/check_mailer/failing_checks.html.erb +7 -0
  66. data/app/views/blazer/check_mailer/state_change.html.erb +48 -0
  67. data/app/views/blazer/checks/_form.html.erb +79 -0
  68. data/app/views/blazer/checks/edit.html.erb +3 -0
  69. data/app/views/blazer/checks/index.html.erb +72 -0
  70. data/app/views/blazer/checks/new.html.erb +3 -0
  71. data/app/views/blazer/dashboards/_form.html.erb +82 -0
  72. data/app/views/blazer/dashboards/edit.html.erb +3 -0
  73. data/app/views/blazer/dashboards/new.html.erb +3 -0
  74. data/app/views/blazer/dashboards/show.html.erb +53 -0
  75. data/app/views/blazer/queries/_caching.html.erb +16 -0
  76. data/app/views/blazer/queries/_cohorts.html.erb +48 -0
  77. data/app/views/blazer/queries/_form.html.erb +255 -0
  78. data/app/views/blazer/queries/docs.html.erb +147 -0
  79. data/app/views/blazer/queries/edit.html.erb +2 -0
  80. data/app/views/blazer/queries/home.html.erb +169 -0
  81. data/app/views/blazer/queries/new.html.erb +2 -0
  82. data/app/views/blazer/queries/run.html.erb +188 -0
  83. data/app/views/blazer/queries/schema.html.erb +55 -0
  84. data/app/views/blazer/queries/show.html.erb +72 -0
  85. data/app/views/blazer/uploads/_form.html.erb +27 -0
  86. data/app/views/blazer/uploads/edit.html.erb +3 -0
  87. data/app/views/blazer/uploads/index.html.erb +55 -0
  88. data/app/views/blazer/uploads/new.html.erb +3 -0
  89. data/app/views/layouts/blazer/application.html.erb +25 -0
  90. data/config/routes.rb +25 -0
  91. data/lib/blazer/adapters/athena_adapter.rb +182 -0
  92. data/lib/blazer/adapters/base_adapter.rb +76 -0
  93. data/lib/blazer/adapters/bigquery_adapter.rb +79 -0
  94. data/lib/blazer/adapters/cassandra_adapter.rb +70 -0
  95. data/lib/blazer/adapters/clickhouse_adapter.rb +84 -0
  96. data/lib/blazer/adapters/drill_adapter.rb +38 -0
  97. data/lib/blazer/adapters/druid_adapter.rb +102 -0
  98. data/lib/blazer/adapters/elasticsearch_adapter.rb +61 -0
  99. data/lib/blazer/adapters/hive_adapter.rb +55 -0
  100. data/lib/blazer/adapters/ignite_adapter.rb +64 -0
  101. data/lib/blazer/adapters/influxdb_adapter.rb +57 -0
  102. data/lib/blazer/adapters/neo4j_adapter.rb +62 -0
  103. data/lib/blazer/adapters/opensearch_adapter.rb +52 -0
  104. data/lib/blazer/adapters/presto_adapter.rb +54 -0
  105. data/lib/blazer/adapters/salesforce_adapter.rb +50 -0
  106. data/lib/blazer/adapters/snowflake_adapter.rb +82 -0
  107. data/lib/blazer/adapters/soda_adapter.rb +105 -0
  108. data/lib/blazer/adapters/spark_adapter.rb +14 -0
  109. data/lib/blazer/adapters/sql_adapter.rb +324 -0
  110. data/lib/blazer/adapters.rb +18 -0
  111. data/lib/blazer/annotations.rb +47 -0
  112. data/lib/blazer/anomaly_detectors.rb +22 -0
  113. data/lib/blazer/check_mailer.rb +27 -0
  114. data/lib/blazer/data_source.rb +270 -0
  115. data/lib/blazer/engine.rb +42 -0
  116. data/lib/blazer/forecasters.rb +7 -0
  117. data/lib/blazer/result.rb +178 -0
  118. data/lib/blazer/result_cache.rb +71 -0
  119. data/lib/blazer/run_statement.rb +44 -0
  120. data/lib/blazer/run_statement_job.rb +20 -0
  121. data/lib/blazer/slack_notifier.rb +94 -0
  122. data/lib/blazer/statement.rb +77 -0
  123. data/lib/blazer/version.rb +3 -0
  124. data/lib/blazer.rb +286 -0
  125. data/lib/finery.rb +3 -0
  126. data/lib/generators/blazer/install_generator.rb +22 -0
  127. data/lib/generators/blazer/templates/config.yml.tt +83 -0
  128. data/lib/generators/blazer/templates/install.rb.tt +47 -0
  129. data/lib/generators/blazer/templates/uploads.rb.tt +10 -0
  130. data/lib/generators/blazer/uploads_generator.rb +18 -0
  131. data/lib/tasks/blazer.rake +20 -0
  132. data/lib/tasks/finery.rake +20 -0
  133. data/licenses/LICENSE-ace.txt +24 -0
  134. data/licenses/LICENSE-bootstrap.txt +21 -0
  135. data/licenses/LICENSE-chart.js.txt +9 -0
  136. data/licenses/LICENSE-chartjs-adapter-date-fns.txt +9 -0
  137. data/licenses/LICENSE-chartkick.js.txt +22 -0
  138. data/licenses/LICENSE-date-fns.txt +21 -0
  139. data/licenses/LICENSE-daterangepicker.txt +21 -0
  140. data/licenses/LICENSE-fuzzysearch.txt +20 -0
  141. data/licenses/LICENSE-highlight.js.txt +29 -0
  142. data/licenses/LICENSE-jquery.txt +20 -0
  143. data/licenses/LICENSE-kurkle-color.txt +9 -0
  144. data/licenses/LICENSE-mapkick-bundle.txt +1029 -0
  145. data/licenses/LICENSE-moment-timezone.txt +20 -0
  146. data/licenses/LICENSE-moment.txt +22 -0
  147. data/licenses/LICENSE-rails-ujs.txt +20 -0
  148. data/licenses/LICENSE-selectize.txt +202 -0
  149. data/licenses/LICENSE-sortable.txt +21 -0
  150. data/licenses/LICENSE-stickytableheaders.txt +20 -0
  151. data/licenses/LICENSE-stupidtable.txt +19 -0
  152. data/licenses/LICENSE-vue.txt +21 -0
  153. metadata +250 -0
@@ -0,0 +1,468 @@
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
+ @annotations = Blazer.annotations.new(@data_source.annotations).call(@result)
262
+ @geojson = []
263
+ set_map_data if Blazer.maps?
264
+
265
+ render_cohort_analysis if @cohort_analysis && !@error
266
+
267
+ respond_to do |format|
268
+ format.html do
269
+ render layout: false
270
+ end
271
+ format.csv do
272
+ # not ideal, but useful for testing
273
+ raise Error, @error if @error && Rails.env.test?
274
+
275
+ data = csv_data(@columns, @rows, @data_source)
276
+ filename = "#{@query.try(:name).try(:parameterize).presence || 'query'}.csv"
277
+ send_data data, type: "text/csv; charset=utf-8", disposition: "attachment", filename: filename
278
+ end
279
+ end
280
+ end
281
+
282
+ def set_map_data
283
+ [["latitude", "longitude"], ["lat", "lon"], ["lat", "lng"]].each do |keys|
284
+ lat_index = @columns.index(keys.first)
285
+ lon_index = @columns.index(keys.last)
286
+ if lat_index && lon_index
287
+ @markers =
288
+ @rows.select do |r|
289
+ r[lat_index] && r[lon_index]
290
+ end.map do |r|
291
+ {
292
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == lat_index || i == lon_index }),
293
+ latitude: r[lat_index],
294
+ longitude: r[lon_index]
295
+ }
296
+ end
297
+
298
+ return if @markers.any?
299
+ end
300
+ end
301
+
302
+ geo_index = @columns.index("geojson")
303
+ if geo_index
304
+ @geojson =
305
+ @rows.filter_map do |r|
306
+ if r[geo_index].is_a?(String) && (geometry = (JSON.parse(r[geo_index]) rescue nil)) && geometry.is_a?(Hash)
307
+ {
308
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == geo_index }),
309
+ geometry: geometry
310
+ }
311
+ end
312
+ end
313
+ end
314
+ end
315
+
316
+ def map_tooltip(r)
317
+ r.map { |v, i| "<strong>#{ERB::Util.html_escape(@columns[i])}:</strong> #{ERB::Util.html_escape(v)}" }.join("<br>").truncate(140, separator: " ")
318
+ end
319
+
320
+ def set_queries(limit = nil)
321
+ @queries = Blazer::Query.named.select(:id, :name, :creator_id, :statement)
322
+ @queries = @queries.includes(:creator) if Blazer.user_class
323
+
324
+ if blazer_user && params[:filter] == "mine"
325
+ @queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
326
+ elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
327
+ @queries = queries_by_ids(Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq)
328
+ else
329
+ @queries = @queries.limit(limit) if limit
330
+ @queries = @queries.active.order(:name)
331
+ end
332
+ @queries = @queries.to_a
333
+
334
+ @more = limit && @queries.size >= limit
335
+
336
+ @queries = @queries.select { |q| !q.name.to_s.start_with?("#") || q.try(:creator).try(:id) == blazer_user.try(:id) }
337
+
338
+ @queries =
339
+ @queries.map do |q|
340
+ {
341
+ id: q.id,
342
+ name: q.name,
343
+ creator: blazer_user && q.try(:creator) == blazer_user ? "You" : q.try(:creator).try(Blazer.user_name),
344
+ vars: q.variables.join(", "),
345
+ to_param: q.to_param
346
+ }
347
+ end
348
+ end
349
+
350
+ def queries_by_ids(favorite_query_ids)
351
+ queries = Blazer::Query.active.named.where(id: favorite_query_ids)
352
+ queries = queries.includes(:creator) if Blazer.user_class
353
+ queries = queries.index_by(&:id)
354
+ favorite_query_ids.map { |query_id| queries[query_id] }.compact
355
+ end
356
+
357
+ def set_query
358
+ @query = Blazer::Query.find(params[:id].to_s.split("-").first)
359
+ end
360
+
361
+ def render_forbidden
362
+ render plain: "Access denied", status: :forbidden
363
+ end
364
+
365
+ def query_params
366
+ params.require(:query).permit(:name, :description, :statement, :data_source)
367
+ end
368
+
369
+ def blazer_params
370
+ params[:blazer] || {}
371
+ end
372
+
373
+ def csv_data(columns, rows, data_source)
374
+ CSV.generate do |csv|
375
+ csv << columns
376
+ rows.each do |row|
377
+ csv << row.each_with_index.map { |v, i| v.is_a?(Time) ? blazer_time_value(data_source, columns[i], v) : v }
378
+ end
379
+ end
380
+ end
381
+
382
+ def blazer_time_value(data_source, k, v)
383
+ data_source.local_time_suffix.any? { |s| k.ends_with?(s) } ? v.to_s.sub(" UTC", "") : v.in_time_zone(Blazer.time_zone)
384
+ end
385
+ helper_method :blazer_time_value
386
+
387
+ def blazer_run_id
388
+ params[:run_id].to_s.gsub(/[^a-z0-9\-]/i, "")
389
+ end
390
+
391
+ def run_cohort_analysis
392
+ unless @statement.data_source.supports_cohort_analysis?
393
+ @cohort_error = "This data source does not support cohort analysis"
394
+ end
395
+
396
+ @show_cohort_rows = !params[:query_id] || @cohort_error
397
+ cohort_analysis_statement(@statement) unless @show_cohort_rows
398
+ end
399
+
400
+ def render_cohort_analysis
401
+ if @show_cohort_rows
402
+ @cohort_analysis = false
403
+
404
+ @row_limit = 1000
405
+
406
+ # check results
407
+ unless @cohort_error
408
+ # check names
409
+ expected_columns = ["user_id", "conversion_time"]
410
+ missing_columns = expected_columns - @result.columns
411
+ if missing_columns.any?
412
+ @cohort_error = "Expected user_id and conversion_time columns"
413
+ end
414
+
415
+ # check types (user_id can be any type)
416
+ unless @cohort_error
417
+ column_types = @result.columns.zip(@result.column_types).to_h
418
+
419
+ if !column_types["cohort_time"].in?(["time", nil])
420
+ @cohort_error = "cohort_time must be time column"
421
+ elsif !column_types["conversion_time"].in?(["time", nil])
422
+ @cohort_error = "conversion_time must be time column"
423
+ end
424
+ end
425
+ end
426
+ else
427
+ @today = Blazer.time_zone.today
428
+ @min_cohort_date, @max_cohort_date = @result.rows.map { |r| r[0] }.minmax
429
+ @buckets = {}
430
+ @rows.each do |r|
431
+ @buckets[[r[0], r[1]]] = r[2]
432
+ end
433
+
434
+ @cohort_dates = []
435
+ current_date = @max_cohort_date
436
+ while current_date && current_date >= @min_cohort_date
437
+ @cohort_dates << current_date
438
+ current_date =
439
+ case @cohort_period
440
+ when "day"
441
+ current_date - 1
442
+ when "week"
443
+ current_date - 7
444
+ else
445
+ current_date.prev_month
446
+ end
447
+ end
448
+
449
+ num_cols = @cohort_dates.size
450
+ @columns = ["Cohort", "Users"] + num_cols.times.map { |i| "#{@conversion_period.titleize} #{i + 1}" }
451
+ rows = []
452
+ date_format = @cohort_period == "month" ? "%b %Y" : "%b %-e, %Y"
453
+ @cohort_dates.each do |date|
454
+ row = [date.strftime(date_format), @buckets[[date, 0]] || 0]
455
+
456
+ num_cols.times do |i|
457
+ if @today >= date + (@cohort_days * i)
458
+ row << (@buckets[[date, i + 1]] || 0)
459
+ end
460
+ end
461
+
462
+ rows << row
463
+ end
464
+ @rows = rows
465
+ end
466
+ end
467
+ end
468
+ 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,83 @@
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
+
39
+ def blazer_format_annotations(annotations)
40
+ return [] unless annotations.is_a?(Array)
41
+ sorted = annotations.sort_by { |annotation| annotation[:min_date] }
42
+
43
+ boxes = sorted.select { |annotation| annotation[:max_date] }.map.with_index do |annotation, index|
44
+ {
45
+ type: "box",
46
+ xScaleID: "x",
47
+ xMin: annotation[:min_date],
48
+ xMax: annotation[:max_date],
49
+ backgroundColor: annotation[:color] || blazer_map_annotation_box_colors(index),
50
+ }
51
+ end
52
+
53
+ # chartjs annotations don't support labels for box annotations
54
+ labels = sorted.select { |annotation| annotation[:label] }.map.with_index do |annotation, index|
55
+ {
56
+ type: "line",
57
+ value: annotation[:min_date],
58
+ mode: "vertical",
59
+ scaleID: "x",
60
+ adjustScaleRange: false,
61
+ borderColor: annotation[:color] || '#00000050',
62
+ drawTime: "afterDatasetsDraw",
63
+ label: {
64
+ content: annotation[:label],
65
+ enabled: true,
66
+ position: "bottom",
67
+ yAdjust: 30 + (index * 30) % 60,
68
+ },
69
+ }
70
+ end
71
+
72
+ boxes + labels
73
+ end
74
+
75
+ private
76
+
77
+ def blazer_map_annotation_box_colors(index)
78
+ colors = ['#aec7e8', '#ffbb78', '#98df8a', '#ff9896', '#c5b0d5', '#c49c94', '#f7b6d2', '#c7c7c7', '#dbdb8d', '#9edae5']
79
+ colors[index % colors.size] + 'da'
80
+ end
81
+ end
82
+ end
83
+
@@ -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