blazer 2.2.6 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of blazer might be problematic. Click here for more details.

Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +1 -1
  4. data/README.md +105 -29
  5. data/app/assets/javascripts/blazer/Chart.js +13794 -12099
  6. data/app/assets/javascripts/blazer/Sortable.js +3695 -1526
  7. data/app/assets/javascripts/blazer/chartkick.js +296 -46
  8. data/app/assets/javascripts/blazer/daterangepicker.js +194 -269
  9. data/app/assets/javascripts/blazer/jquery.js +1150 -642
  10. data/app/assets/javascripts/blazer/moment-timezone-with-data.js +621 -287
  11. data/app/assets/javascripts/blazer/moment.js +5085 -2460
  12. data/app/assets/stylesheets/blazer/application.css +4 -0
  13. data/app/assets/stylesheets/blazer/daterangepicker.css +394 -253
  14. data/app/controllers/blazer/base_controller.rb +12 -4
  15. data/app/controllers/blazer/dashboards_controller.rb +7 -2
  16. data/app/controllers/blazer/queries_controller.rb +119 -3
  17. data/app/controllers/blazer/uploads_controller.rb +147 -0
  18. data/app/mailers/blazer/slack_notifier.rb +1 -1
  19. data/app/models/blazer/query.rb +8 -1
  20. data/app/models/blazer/upload.rb +11 -0
  21. data/app/models/blazer/uploads_connection.rb +7 -0
  22. data/app/views/blazer/_nav.html.erb +3 -0
  23. data/app/views/blazer/_variables.html.erb +3 -1
  24. data/app/views/blazer/checks/_form.html.erb +1 -1
  25. data/app/views/blazer/checks/index.html.erb +3 -0
  26. data/app/views/blazer/dashboards/_form.html.erb +1 -1
  27. data/app/views/blazer/dashboards/show.html.erb +1 -1
  28. data/app/views/blazer/queries/_caching.html.erb +16 -0
  29. data/app/views/blazer/queries/_cohorts.html.erb +48 -0
  30. data/app/views/blazer/queries/docs.html.erb +6 -0
  31. data/app/views/blazer/queries/home.html.erb +3 -0
  32. data/app/views/blazer/queries/run.html.erb +20 -22
  33. data/app/views/blazer/queries/show.html.erb +1 -1
  34. data/app/views/blazer/uploads/_form.html.erb +27 -0
  35. data/app/views/blazer/uploads/edit.html.erb +3 -0
  36. data/app/views/blazer/uploads/index.html.erb +55 -0
  37. data/app/views/blazer/uploads/new.html.erb +3 -0
  38. data/config/routes.rb +5 -0
  39. data/lib/blazer.rb +18 -0
  40. data/lib/blazer/adapters/base_adapter.rb +8 -0
  41. data/lib/blazer/adapters/sql_adapter.rb +42 -1
  42. data/lib/blazer/data_source.rb +1 -1
  43. data/lib/blazer/version.rb +1 -1
  44. data/lib/generators/blazer/templates/config.yml.tt +6 -0
  45. data/lib/generators/blazer/templates/install.rb.tt +3 -2
  46. data/lib/generators/blazer/templates/uploads.rb.tt +10 -0
  47. data/lib/generators/blazer/uploads_generator.rb +18 -0
  48. data/lib/tasks/blazer.rake +9 -0
  49. data/licenses/LICENSE-ace.txt +24 -0
  50. data/licenses/LICENSE-bootstrap.txt +21 -0
  51. data/licenses/LICENSE-chart.js.txt +9 -0
  52. data/licenses/LICENSE-chartkick.js.txt +22 -0
  53. data/licenses/LICENSE-daterangepicker.txt +21 -0
  54. data/licenses/LICENSE-fuzzysearch.txt +20 -0
  55. data/licenses/LICENSE-highlight.js.txt +29 -0
  56. data/licenses/LICENSE-jquery-ujs.txt +20 -0
  57. data/licenses/LICENSE-jquery.txt +20 -0
  58. data/licenses/LICENSE-moment-timezone.txt +20 -0
  59. data/licenses/LICENSE-moment.txt +22 -0
  60. data/licenses/LICENSE-selectize.txt +202 -0
  61. data/licenses/LICENSE-sortable.txt +21 -0
  62. data/licenses/LICENSE-stickytableheaders.txt +20 -0
  63. data/licenses/LICENSE-stupidtable.txt +19 -0
  64. data/licenses/LICENSE-vue.txt +21 -0
  65. metadata +34 -7
@@ -6,6 +6,8 @@ module Blazer
6
6
  skip_after_action(*filters, raise: false)
7
7
  skip_around_action(*filters, raise: false)
8
8
 
9
+ clear_helpers
10
+
9
11
  protect_from_forgery with: :exception
10
12
 
11
13
  if ENV["BLAZER_PASSWORD"]
@@ -65,6 +67,12 @@ module Blazer
65
67
  end
66
68
  end
67
69
 
70
+ def add_cohort_analysis_vars
71
+ @bind_vars << "cohort_period" unless @bind_vars.include?("cohort_period")
72
+ @smart_vars["cohort_period"] = ["day", "week", "month"]
73
+ params[:cohort_period] ||= "week"
74
+ end
75
+
68
76
  def parse_smart_variables(var, data_source)
69
77
  smart_var_data_source =
70
78
  ([data_source] + Array(data_source.settings["inherit_smart_settings"]).map { |ds| Blazer.data_sources[ds] }).find { |ds| ds.smart_variables[var] }
@@ -96,12 +104,12 @@ module Blazer
96
104
  # when permitted parameters are passed in Rails 6,
97
105
  # they appear to be added as GET parameters
98
106
  # root_url(params.permit(:host))
99
- BLACKLISTED_KEYS = [:controller, :action, :id, :host, :query, :dashboard, :query_id, :query_ids, :table_names, :authenticity_token, :utf8, :_method, :commit, :statement, :data_source, :name, :fork_query_id, :blazer, :run_id, :script_name, :original_script_name]
107
+ UNPERMITTED_KEYS = [:controller, :action, :id, :host, :query, :dashboard, :query_id, :query_ids, :table_names, :authenticity_token, :utf8, :_method, :commit, :statement, :data_source, :name, :fork_query_id, :blazer, :run_id, :script_name, :original_script_name]
100
108
 
101
- # remove blacklisted keys from both params and permitted keys for better sleep
109
+ # remove unpermitted keys from both params and permitted keys for better sleep
102
110
  def variable_params(resource)
103
- permitted_keys = resource.variables - BLACKLISTED_KEYS.map(&:to_s)
104
- params.except(*BLACKLISTED_KEYS).permit(*permitted_keys)
111
+ permitted_keys = resource.variables - UNPERMITTED_KEYS.map(&:to_s)
112
+ params.except(*UNPERMITTED_KEYS).slice(*permitted_keys).permit!
105
113
  end
106
114
  helper_method :variable_params
107
115
 
@@ -21,8 +21,11 @@ module Blazer
21
21
 
22
22
  def show
23
23
  @queries = @dashboard.dashboard_queries.order(:position).preload(:query).map(&:query)
24
+ @statements = []
24
25
  @queries.each do |query|
25
- process_vars(query.statement, query.data_source)
26
+ statement = query.statement.dup
27
+ process_vars(statement, query.data_source)
28
+ @statements << statement
26
29
  end
27
30
  @bind_vars ||= []
28
31
 
@@ -36,6 +39,8 @@ module Blazer
36
39
  @sql_errors << error if error
37
40
  end
38
41
  end
42
+
43
+ add_cohort_analysis_vars if @queries.any?(&:cohort_analysis?)
39
44
  end
40
45
 
41
46
  def edit
@@ -51,7 +56,7 @@ module Blazer
51
56
 
52
57
  def destroy
53
58
  @dashboard.destroy
54
- redirect_to dashboards_path
59
+ redirect_to root_path
55
60
  end
56
61
 
57
62
  def refresh
@@ -38,11 +38,18 @@ module Blazer
38
38
  if params[:fork_query_id]
39
39
  @query.statement ||= Blazer::Query.find(params[:fork_query_id]).try(:statement)
40
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
41
47
  end
42
48
 
43
49
  def create
44
50
  @query = Blazer::Query.new(query_params)
45
51
  @query.creator = blazer_user if @query.respond_to?(:creator)
52
+ @query.status = "active" if @query.respond_to?(:status)
46
53
 
47
54
  if @query.save
48
55
  redirect_to query_path(@query, variable_params(@query))
@@ -64,7 +71,11 @@ module Blazer
64
71
  @sql_errors << error if error
65
72
  end
66
73
 
74
+ @query.update!(status: "active") if @query.try(:status) == "archived"
75
+
67
76
  Blazer.transform_statement.call(data_source, @statement) if Blazer.transform_statement
77
+
78
+ add_cohort_analysis_vars if @query.cohort_analysis?
68
79
  end
69
80
 
70
81
  def edit
@@ -72,6 +83,8 @@ module Blazer
72
83
 
73
84
  def run
74
85
  @statement = params[:statement]
86
+ # before process_vars
87
+ @cohort_analysis = Query.new(statement: @statement).cohort_analysis?
75
88
  data_source = params[:data_source]
76
89
  process_vars(@statement, data_source)
77
90
  @only_chart = params[:only_chart]
@@ -80,6 +93,8 @@ module Blazer
80
93
  data_source = @query.data_source if @query && @query.data_source
81
94
  @data_source = Blazer.data_sources[data_source]
82
95
 
96
+ run_cohort_analysis if @cohort_analysis
97
+
83
98
  # ensure viewable
84
99
  if !(@query || Query.new(data_source: @data_source.id)).viewable?(blazer_user)
85
100
  render_forbidden
@@ -155,6 +170,7 @@ module Blazer
155
170
  @statement = @query.statement.dup
156
171
  process_vars(@statement, @query.data_source)
157
172
  Blazer.transform_statement.call(data_source, @statement) if Blazer.transform_statement
173
+ @statement = cohort_analysis_statement(data_source, @statement) if @query.cohort_analysis?
158
174
  data_source.clear_cache(@statement)
159
175
  redirect_to query_path(@query, variable_params(@query))
160
176
  end
@@ -232,7 +248,6 @@ module Blazer
232
248
  end
233
249
  end
234
250
 
235
- @filename = @query.name.parameterize if @query
236
251
  @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)
237
252
 
238
253
  @boom = @result.boom if @result
@@ -259,6 +274,8 @@ module Blazer
259
274
  end
260
275
  end
261
276
 
277
+ render_cohort_analysis if @cohort_analysis && !@error
278
+
262
279
  respond_to do |format|
263
280
  format.html do
264
281
  render layout: false
@@ -279,7 +296,7 @@ module Blazer
279
296
  @queries = queries_by_ids(Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq)
280
297
  else
281
298
  @queries = @queries.limit(limit) if limit
282
- @queries = @queries.order(:name)
299
+ @queries = @queries.active.order(:name)
283
300
  end
284
301
  @queries = @queries.to_a
285
302
 
@@ -300,7 +317,7 @@ module Blazer
300
317
  end
301
318
 
302
319
  def queries_by_ids(favorite_query_ids)
303
- queries = Blazer::Query.named.where(id: favorite_query_ids)
320
+ queries = Blazer::Query.active.named.where(id: favorite_query_ids)
304
321
  queries = queries.includes(:creator) if Blazer.user_class
305
322
  queries = queries.index_by(&:id)
306
323
  favorite_query_ids.map { |query_id| queries[query_id] }.compact
@@ -343,5 +360,104 @@ module Blazer
343
360
  def blazer_run_id
344
361
  params[:run_id].to_s.gsub(/[^a-z0-9\-]/i, "")
345
362
  end
363
+
364
+ def run_cohort_analysis
365
+ unless @data_source.supports_cohort_analysis?
366
+ @cohort_error = "This data source does not support cohort analysis"
367
+ end
368
+
369
+ @show_cohort_rows = !params[:query_id] || @cohort_error
370
+
371
+ unless @show_cohort_rows
372
+ @statement = cohort_analysis_statement(@data_source, @statement)
373
+ end
374
+ end
375
+
376
+ def cohort_analysis_statement(data_source, statement)
377
+ @cohort_period = params["cohort_period"] || "week"
378
+ @cohort_period = "week" unless ["day", "week", "month"].include?(@cohort_period)
379
+
380
+ # for now
381
+ @conversion_period = @cohort_period
382
+ @cohort_days =
383
+ case @cohort_period
384
+ when "day"
385
+ 1
386
+ when "week"
387
+ 7
388
+ when "month"
389
+ 30
390
+ end
391
+
392
+ data_source.cohort_analysis_statement(statement, period: @cohort_period, days: @cohort_days)
393
+ end
394
+
395
+ def render_cohort_analysis
396
+ if @show_cohort_rows
397
+ @cohort_analysis = false
398
+
399
+ @row_limit = 1000
400
+
401
+ # check results
402
+ unless @cohort_error
403
+ # check names
404
+ expected_columns = ["user_id", "conversion_time"]
405
+ missing_columns = expected_columns - @result.columns
406
+ if missing_columns.any?
407
+ @cohort_error = "Expected user_id and conversion_time columns"
408
+ end
409
+
410
+ # check types (user_id can be any type)
411
+ unless @cohort_error
412
+ column_types = @result.columns.zip(@result.column_types).to_h
413
+
414
+ if !column_types["cohort_time"].in?(["time", nil])
415
+ @cohort_error = "cohort_time must be time column"
416
+ elsif !column_types["conversion_time"].in?(["time", nil])
417
+ @cohort_error = "conversion_time must be time column"
418
+ end
419
+ end
420
+ end
421
+ else
422
+ @today = Blazer.time_zone.today
423
+ @min_cohort_date, @max_cohort_date = @result.rows.map { |r| r[0] }.minmax
424
+ @buckets = {}
425
+ @rows.each do |r|
426
+ @buckets[[r[0], r[1]]] = r[2]
427
+ end
428
+
429
+ @cohort_dates = []
430
+ current_date = @max_cohort_date
431
+ while current_date && current_date >= @min_cohort_date
432
+ @cohort_dates << current_date
433
+ current_date =
434
+ case @cohort_period
435
+ when "day"
436
+ current_date - 1
437
+ when "week"
438
+ current_date - 7
439
+ else
440
+ current_date.prev_month
441
+ end
442
+ end
443
+
444
+ num_cols = @cohort_dates.size
445
+ @columns = ["Cohort", "Users"] + num_cols.times.map { |i| "#{@conversion_period.titleize} #{i + 1}" }
446
+ rows = []
447
+ date_format = @cohort_period == "month" ? "%b %Y" : "%b %-e, %Y"
448
+ @cohort_dates.each do |date|
449
+ row = [date.strftime(date_format), @buckets[[date, 0]] || 0]
450
+
451
+ num_cols.times do |i|
452
+ if @today >= date + (@cohort_days * i)
453
+ row << (@buckets[[date, i + 1]] || 0)
454
+ end
455
+ end
456
+
457
+ rows << row
458
+ end
459
+ @rows = rows
460
+ end
461
+ end
346
462
  end
347
463
  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
@@ -62,7 +62,7 @@ module Blazer
62
62
 
63
63
  # checks shouldn't have variables, but in any case,
64
64
  # avoid passing variable params to url helpers
65
- # (known unsafe parameters are removed, but blacklist isn't ideal)
65
+ # (known unsafe parameters are removed, but still not ideal)
66
66
  def self.query_url(id)
67
67
  Blazer::Engine.routes.url_helpers.query_url(id, ActionMailer::Base.default_url_options)
68
68
  end
@@ -8,6 +8,7 @@ module Blazer
8
8
 
9
9
  validates :statement, presence: true
10
10
 
11
+ scope :active, -> { column_names.include?("status") ? where(status: "active") : all }
11
12
  scope :named, -> { where("blazer_queries.name <> ''") }
12
13
 
13
14
  def to_param
@@ -34,7 +35,13 @@ module Blazer
34
35
  end
35
36
 
36
37
  def variables
37
- Blazer.extract_vars(statement)
38
+ variables = Blazer.extract_vars(statement)
39
+ variables += ["cohort_period"] if cohort_analysis?
40
+ variables
41
+ end
42
+
43
+ def cohort_analysis?
44
+ /\/\*\s*cohort analysis\s*\*\//i.match?(statement)
38
45
  end
39
46
  end
40
47
  end
@@ -0,0 +1,11 @@
1
+ module Blazer
2
+ class Upload < Record
3
+ belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class
4
+
5
+ validates :table, presence: true, uniqueness: true, format: {with: /\A[a-z0-9_]+\z/, message: "can only contain lowercase letters, numbers, and underscores"}, length: {maximum: 63}
6
+
7
+ def table_name
8
+ Blazer.uploads_table_name(table)
9
+ end
10
+ end
11
+ end