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
@@ -0,0 +1,7 @@
1
+ module Blazer
2
+ class UploadsConnection < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ establish_connection Blazer.settings["uploads"]["url"] if Blazer.uploads?
6
+ end
7
+ end
@@ -6,6 +6,9 @@
6
6
  </button>
7
7
  <ul class="dropdown-menu">
8
8
  <li><%= link_to "Checks", checks_path %></li>
9
+ <% if Blazer.uploads? %>
10
+ <li><%= link_to "Uploads", uploads_path %></li>
11
+ <% end %>
9
12
  <li role="separator" class="divider"></li>
10
13
  <li><%= link_to "New Query", new_query_path %></li>
11
14
  <li><%= link_to "New Dashboard", new_dashboard_path %></li>
@@ -42,6 +42,7 @@
42
42
  singleDatePicker: true,
43
43
  locale: {format: format},
44
44
  autoUpdateInput: false,
45
+ autoApply: true,
45
46
  startDate: input.val().length > 0 ? moment.tz(input.val(), timeZone) : now
46
47
  })
47
48
  // hack to start with empty date
@@ -95,7 +96,8 @@
95
96
  },
96
97
  startDate: dateStr(29),
97
98
  endDate: dateStr(),
98
- opens: "right"
99
+ opens: "right",
100
+ alwaysShowCalendars: true
99
101
  },
100
102
  function(start, end) {
101
103
  setTimeInputs(start, end)
@@ -13,7 +13,7 @@
13
13
  <%= f.select :query_id, [], {include_blank: true} %>
14
14
  </div>
15
15
  <script>
16
- <%= blazer_js_var "queries", Blazer::Query.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %>
16
+ <%= blazer_js_var "queries", Blazer::Query.active.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %>
17
17
  <%= blazer_js_var "items", [@check.query_id].compact %>
18
18
 
19
19
  $("#check_query_id").selectize({options: queries, items: items, highlight: false, maxOptions: 100}).parents(".hide").removeClass("hide");
@@ -10,6 +10,9 @@
10
10
  </button>
11
11
  <ul class="dropdown-menu">
12
12
  <li><%= link_to "Home", root_path %></li>
13
+ <% if Blazer.uploads? %>
14
+ <li><%= link_to "Uploads", uploads_path %></li>
15
+ <% end %>
13
16
  <li role="separator" class="divider"></li>
14
17
  <li><%= link_to "New Query", new_query_path %></li>
15
18
  <li><%= link_to "New Dashboard", new_dashboard_path %></li>
@@ -31,7 +31,7 @@
31
31
  <% end %>
32
32
 
33
33
  <script>
34
- <%= blazer_js_var "queries", Blazer::Query.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %>
34
+ <%= blazer_js_var "queries", Blazer::Query.active.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %>
35
35
  <%= blazer_js_var "dashboardQueries", @queries || @dashboard.dashboard_queries.order(:position).map(&:query) %>
36
36
 
37
37
  var app = new Vue({
@@ -39,7 +39,7 @@
39
39
  </div>
40
40
  </div>
41
41
  <script>
42
- <%= blazer_js_var "data", {statement: query.statement, query_id: query.id, data_source: query.data_source, only_chart: true} %>
42
+ <%= blazer_js_var "data", {statement: @statements[i], query_id: query.id, data_source: query.data_source, only_chart: true, cohort_period: params[:cohort_period]} %>
43
43
 
44
44
  runQuery(data, function (data) {
45
45
  $("#chart-<%= i %>").html(data)
@@ -0,0 +1,16 @@
1
+ <% if @cached_at || @just_cached %>
2
+ <p class="text-muted" style="float: right;">
3
+ <% if @cached_at %>
4
+ Cached <%= time_ago_in_words(@cached_at, include_seconds: true) %> ago
5
+ <% elsif params[:query_id] %>
6
+ Cached just now
7
+ <% if @data_source.cache_mode == "slow" %>
8
+ (over <%= "%g" % @data_source.cache_slow_threshold %>s)
9
+ <% end %>
10
+ <% end %>
11
+
12
+ <% if @query && params[:query_id] %>
13
+ <%= link_to "Refresh", refresh_query_path(@query, variable_params(@query)), method: :post %>
14
+ <% end %>
15
+ </p>
16
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <% unless @only_chart %>
2
+ <%= render partial: "caching" %>
3
+ <p class="text-muted" style="margin-bottom: 10px;">
4
+ <%= pluralize(@rows.size, "cohort") %>
5
+ </p>
6
+ <% end %>
7
+ <% if @rows.any? %>
8
+ <div class="results-container">
9
+ <table class="table results-table">
10
+ <thead>
11
+ <tr>
12
+ <th style="min-width: 100px;">Cohort</th>
13
+ <% 12.times do |i| %>
14
+ <th style="width: 7.5%; text-align: right;"><%= @conversion_period.titleize %> <%= i + 1 %></th>
15
+ <% end %>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @rows.each do |row| %>
20
+ <tr>
21
+ <td>
22
+ <%= row[0] %>
23
+ <div style="font-size: 12px; color: #999;"><%= row[1] == 1 ? "1 user" : "#{number_with_delimiter(row[1])} users" %></div>
24
+ </td>
25
+ <% 12.times do |i| %>
26
+ <td style="text-align: right;">
27
+ <% num = row[i + 2] %>
28
+ <% if num %>
29
+ <% denom = row[1] %>
30
+ <% if denom > 0 %>
31
+ <%= (100.0 * num / denom).round %>%
32
+ <% else %>
33
+ -
34
+ <% end %>
35
+ <div style="font-size: 12px; color: #999;"><%= number_with_delimiter(num) %></div>
36
+ <% else %>
37
+ -
38
+ <% end %>
39
+ </td>
40
+ <% end %>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ <% elsif @only_chart %>
47
+ <p class="text-muted">No cohorts</p>
48
+ <% end %>
@@ -129,3 +129,9 @@
129
129
  </table>
130
130
 
131
131
  <p>Use the column name <code>target</code> to draw a line for goals.</p>
132
+
133
+ <% if @data_source.supports_cohort_analysis? %>
134
+ <h2>Cohort Analysis</h2>
135
+
136
+ <p>Create a query with the comment <code>/* cohort analysis */</code>. The result should have columns named <code>user_id</code> and <code>conversion_time</code> and optionally <code>cohort_time</code>.</p>
137
+ <% end %>
@@ -19,6 +19,9 @@
19
19
  </button>
20
20
  <ul class="dropdown-menu">
21
21
  <li><%= link_to "Checks", checks_path %></li>
22
+ <% if Blazer.uploads? %>
23
+ <li><%= link_to "Uploads", uploads_path %></li>
24
+ <% end %>
22
25
  <li role="separator" class="divider"></li>
23
26
  <li><%= link_to "New Dashboard", new_dashboard_path %></li>
24
27
  <li><%= link_to "New Check", new_check_path %></li>
@@ -6,25 +6,20 @@
6
6
  <% else %>
7
7
  <div class="alert alert-info">Can’t preview queries with variables...yet!</div>
8
8
  <% end %>
9
+ <% elsif @cohort_analysis %>
10
+ <% if @cohort_error %>
11
+ <div class="alert alert-info"><%= @cohort_error %></div>
12
+ <% else %>
13
+ <%= render partial: "cohorts" %>
14
+ <% end %>
9
15
  <% else %>
10
16
  <% unless @only_chart %>
11
- <% if @cached_at || @just_cached %>
12
- <p class="text-muted" style="float: right;">
13
- <% if @cached_at %>
14
- Cached <%= time_ago_in_words(@cached_at, include_seconds: true) %> ago
15
- <% elsif params[:query_id] %>
16
- Cached just now
17
- <% if @data_source.cache_mode == "slow" %>
18
- (over <%= "%g" % @data_source.cache_slow_threshold %>s)
19
- <% end %>
20
- <% end %>
21
-
22
- <% if @query && params[:query_id] %>
23
- <%= link_to "Refresh", refresh_query_path(@query, variable_params(@query)), method: :post %>
24
- <% end %>
25
- </p>
26
- <% end %>
17
+ <%= render partial: "caching" %>
27
18
  <p class="text-muted" style="margin-bottom: 10px;">
19
+ <% if @row_limit && @rows.size > @row_limit %>
20
+ First
21
+ <% @rows = @rows.first(@row_limit) %>
22
+ <% end %>
28
23
  <%= pluralize(@rows.size, "row") %>
29
24
 
30
25
  <% @checks.select(&:state).each do |check| %>
@@ -43,6 +38,9 @@
43
38
  <% if @forecast_error %>
44
39
  <div class="alert alert-danger"><%= @forecast_error %></div>
45
40
  <% end %>
41
+ <% if @cohort_error %>
42
+ <div class="alert alert-info"><%= @cohort_error %></div>
43
+ <% end %>
46
44
  <% if @rows.any? %>
47
45
  <% values = @rows.first %>
48
46
  <% chart_id = SecureRandom.hex %>
@@ -107,13 +105,13 @@
107
105
  </script>
108
106
  <% elsif chart_type == "line" %>
109
107
  <% chart_data = @columns[1..-1].each_with_index.map{ |k, i| {name: blazer_series_name(k), data: @rows.map{ |r| [r[0], r[i + 1]] }, library: series_library[i]} } %>
110
- <%= line_chart chart_data, chart_options %>
108
+ <%= line_chart chart_data, **chart_options %>
111
109
  <% elsif chart_type == "line2" %>
112
- <%= line_chart @rows.group_by { |r| v = r[1]; (@boom[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.map { |v2| [v2[0], v2[2]] }, library: series_library[i]} }, chart_options %>
110
+ <%= line_chart @rows.group_by { |r| v = r[1]; (@boom[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.map { |v2| [v2[0], v2[2]] }, library: series_library[i]} }, **chart_options %>
113
111
  <% elsif chart_type == "pie" %>
114
- <%= pie_chart @rows.map { |r| [(@boom[@columns[0]] || {})[r[0].to_s] || r[0], r[1]] }, chart_options %>
112
+ <%= pie_chart @rows.map { |r| [(@boom[@columns[0]] || {})[r[0].to_s] || r[0], r[1]] }, **chart_options %>
115
113
  <% elsif chart_type == "bar" %>
116
- <%= column_chart (values.size - 1).times.map { |i| name = @columns[i + 1]; {name: blazer_series_name(name), data: @rows.first(20).map { |r| [(@boom[@columns[0]] || {})[r[0].to_s] || r[0], r[i + 1]] } } }, chart_options %>
114
+ <%= column_chart (values.size - 1).times.map { |i| name = @columns[i + 1]; {name: blazer_series_name(name), data: @rows.first(20).map { |r| [(@boom[@columns[0]] || {})[r[0].to_s] || r[0], r[i + 1]] } } }, **chart_options %>
117
115
  <% elsif chart_type == "bar2" %>
118
116
  <% first_20 = @rows.group_by { |r| r[0] }.values.first(20).flatten(1) %>
119
117
  <% labels = first_20.map { |r| r[0] }.uniq %>
@@ -123,7 +121,7 @@
123
121
  <% first_20 << [l, s, 0] unless first_20.find { |r| r[0] == l && r[1] == s } %>
124
122
  <% end %>
125
123
  <% end %>
126
- <%= column_chart first_20.group_by { |r| v = r[1]; (@boom[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.sort_by { |r2| labels.index(r2[0]) }.map { |v2| v3 = v2[0]; [(@boom[@columns[0]] || {})[v3.to_s] || v3, v2[2]] }} }, chart_options %>
124
+ <%= column_chart first_20.group_by { |r| v = r[1]; (@boom[@columns[1]] || {})[v.to_s] || v }.each_with_index.map { |(name, v), i| {name: blazer_series_name(name), data: v.sort_by { |r2| labels.index(r2[0]) }.map { |v2| v3 = v2[0]; [(@boom[@columns[0]] || {})[v3.to_s] || v3, v2[2]] }} }, **chart_options %>
127
125
  <% elsif chart_type == "scatter" %>
128
126
  <%= scatter_chart @rows, xtitle: @columns[0], ytitle: @columns[1], **chart_options %>
129
127
  <% elsif @only_chart %>
@@ -147,7 +145,7 @@
147
145
  <% elsif @columns == ["PLAN"] && @data_source.adapter == "druid" %>
148
146
  <pre><code><%= @rows[0][0] %></code></pre>
149
147
  <% else %>
150
- <table class="table results-table" style="margin-bottom: 0;">
148
+ <table class="table results-table">
151
149
  <thead>
152
150
  <tr>
153
151
  <% @columns.each_with_index do |key, i| %>
@@ -14,7 +14,7 @@
14
14
  <%= link_to "Fork", new_query_path(variable_params(@query).merge(fork_query_id: @query.id, data_source: @query.data_source, name: @query.name)), class: "btn btn-info" %>
15
15
 
16
16
  <% if !@error && @success %>
17
- <%= button_to "Download", run_queries_path(query_id: @query.id, format: "csv", forecast: params[:forecast]), params: {statement: @statement}, class: "btn btn-primary" %>
17
+ <%= button_to "Download", run_queries_path(query_id: @query.id, format: "csv", forecast: params[:forecast], cohort_period: params[:cohort_period]), params: {statement: @statement}, class: "btn btn-primary" %>
18
18
  <% end %>
19
19
  </div>
20
20
  </div>
@@ -0,0 +1,27 @@
1
+ <%= form_for @upload, html: {class: "small-form"} do |f| %>
2
+ <% if @upload.errors.any? %>
3
+ <div class="alert alert-danger"><%= @upload.errors.full_messages.first %></div>
4
+ <% elsif !@upload.persisted? %>
5
+ <p>Create a database table from a CSV file. The table will be created in the <code><%= Blazer.settings["uploads"]["schema"] %></code> schema.</p>
6
+ <% end %>
7
+
8
+ <div class="form-group">
9
+ <%= f.label :table %>
10
+ <%= f.text_field :table, class: "form-control" %>
11
+ </div>
12
+ <div class="form-group">
13
+ <%= f.label :description %>
14
+ <%= f.text_area :description, placeholder: "Optional", style: "height: 60px;", class: "form-control" %>
15
+ </div>
16
+ <div class="form-group">
17
+ <%= f.label :file %>
18
+ <%= f.file_field :file, accept: "text/csv", style: "margin-top: 6px; margin-bottom: 21px;" %>
19
+ </div>
20
+ <p>
21
+ <% if @upload.persisted? %>
22
+ <%= link_to "Delete", upload_path(@upload), method: :delete, "data-confirm" => "Are you sure?", class: "btn btn-danger" %>
23
+ <% end %>
24
+ <%= f.submit "Save", class: "btn btn-success" %>
25
+ <%= link_to "Back", :back, class: "btn btn-link" %>
26
+ </p>
27
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% blazer_title "Edit Upload" %>
2
+
3
+ <%= render partial: "form" %>
@@ -0,0 +1,55 @@
1
+ <% blazer_title "Uploads" %>
2
+
3
+ <div id="header">
4
+ <div class="pull-right" style="line-height: 34px;">
5
+ <div class="btn-group">
6
+ <%= link_to "New Upload", new_upload_path, class: "btn btn-info" %>
7
+ <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
8
+ <span class="caret"></span>
9
+ <span class="sr-only">Toggle Dropdown</span>
10
+ </button>
11
+ <ul class="dropdown-menu">
12
+ <li><%= link_to "Home", root_path %></li>
13
+ <li><%= link_to "Checks", checks_path %></li>
14
+ <li role="separator" class="divider"></li>
15
+ <li><%= link_to "New Query", new_query_path %></li>
16
+ <li><%= link_to "New Dashboard", new_dashboard_path %></li>
17
+ <li><%= link_to "New Check", new_check_path %></li>
18
+ </ul>
19
+ </div>
20
+ </div>
21
+
22
+ <input id="search" type="text" placeholder="Start typing a table or person" style="width: 300px; display: inline-block;" class="search form-control" />
23
+ </div>
24
+
25
+ <table id="uploads" class="table">
26
+ <thead>
27
+ <tr>
28
+ <th>Table</th>
29
+ <th style="width: 60%;"></th>
30
+ <% if Blazer.user_class %>
31
+ <th style="width: 20%; text-align: right;">Mastermind</th>
32
+ <% end%>
33
+ </tr>
34
+ </thead>
35
+ <tbody>
36
+ <% @uploads.each do |upload| %>
37
+ <tr>
38
+ <td><%= link_to upload.table, edit_upload_path(upload) %></td>
39
+ <td><%= truncate(upload.description, length: 100, separator: " ") %></td>
40
+ <% if Blazer.user_class %>
41
+ <td class="creator"><%= blazer_user && upload.creator == blazer_user ? "You" : upload.creator.try(Blazer.user_name) %></td>
42
+ <% end %>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+
48
+ <script>
49
+ $("#search").on("keyup", function() {
50
+ var value = $(this).val().toLowerCase()
51
+ $("#uploads tbody tr").filter( function() {
52
+ $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1)
53
+ })
54
+ }).focus()
55
+ </script>
@@ -0,0 +1,3 @@
1
+ <% blazer_title "New Upload" %>
2
+
3
+ <%= render partial: "form" %>
@@ -16,5 +16,10 @@ Blazer::Engine.routes.draw do
16
16
  post :refresh, on: :member
17
17
  end
18
18
 
19
+ if Blazer.uploads?
20
+ resources :uploads do
21
+ end
22
+ end
23
+
19
24
  root to: "queries#home"
20
25
  end
@@ -32,6 +32,7 @@ require "blazer/engine"
32
32
 
33
33
  module Blazer
34
34
  class Error < StandardError; end
35
+ class UploadError < Error; end
35
36
  class TimeoutNotSupported < Error; end
36
37
 
37
38
  class << self
@@ -206,6 +207,23 @@ module Blazer
206
207
  slack_webhook_url.present?
207
208
  end
208
209
 
210
+ def self.uploads?
211
+ settings.key?("uploads")
212
+ end
213
+
214
+ def self.uploads_connection
215
+ raise "Empty url for uploads" unless settings.dig("uploads", "url")
216
+ Blazer::UploadsConnection.connection
217
+ end
218
+
219
+ def self.uploads_schema
220
+ settings.dig("uploads", "schema") || "uploads"
221
+ end
222
+
223
+ def self.uploads_table_name(name)
224
+ uploads_connection.quote_table_name("#{uploads_schema}.#{name}")
225
+ end
226
+
209
227
  def self.adapters
210
228
  @adapters ||= {}
211
229
  end
@@ -43,6 +43,14 @@ module Blazer
43
43
  true # optional
44
44
  end
45
45
 
46
+ def supports_cohort_analysis?
47
+ false # optional
48
+ end
49
+
50
+ def cohort_analysis_statement(statement, period:, days:)
51
+ # optional
52
+ end
53
+
46
54
  protected
47
55
 
48
56
  def settings
@@ -27,7 +27,7 @@ module Blazer
27
27
  result = select_all("#{statement} /*#{comment}*/")
28
28
  columns = result.columns
29
29
  result.rows.each do |untyped_row|
30
- rows << (result.column_types.empty? ? untyped_row : columns.each_with_index.map { |c, i| untyped_row[i] ? result.column_types[c].send(:cast_value, untyped_row[i]) : untyped_row[i] })
30
+ rows << (result.column_types.empty? ? untyped_row : columns.each_with_index.map { |c, i| untyped_row[i] && result.column_types[c] ? result.column_types[c].send(:cast_value, untyped_row[i]) : untyped_row[i] })
31
31
  end
32
32
  end
33
33
  rescue => e
@@ -122,6 +122,47 @@ module Blazer
122
122
  !%w[CREATE ALTER UPDATE INSERT DELETE].include?(statement.split.first.to_s.upcase)
123
123
  end
124
124
 
125
+ def supports_cohort_analysis?
126
+ postgresql?
127
+ end
128
+
129
+ # TODO treat date columns as already in time zone
130
+ def cohort_analysis_statement(statement, period:, days:)
131
+ raise "Cohort analysis not supported" unless supports_cohort_analysis?
132
+
133
+ cohort_column = statement =~ /\bcohort_time\b/ ? "cohort_time" : "conversion_time"
134
+
135
+ # WITH not an optimization fence in Postgres 12+
136
+ statement = <<~SQL
137
+ WITH query AS (
138
+ #{statement}
139
+ ),
140
+ cohorts AS (
141
+ SELECT user_id, MIN(#{cohort_column}) AS cohort_time FROM query
142
+ WHERE user_id IS NOT NULL AND #{cohort_column} IS NOT NULL
143
+ GROUP BY 1
144
+ )
145
+ SELECT
146
+ date_trunc(?, cohorts.cohort_time::timestamptz AT TIME ZONE ?)::date AS period,
147
+ 0 AS bucket,
148
+ COUNT(DISTINCT cohorts.user_id)
149
+ FROM cohorts GROUP BY 1
150
+ UNION ALL
151
+ SELECT
152
+ date_trunc(?, cohorts.cohort_time::timestamptz AT TIME ZONE ?)::date AS period,
153
+ CEIL(EXTRACT(EPOCH FROM query.conversion_time - cohorts.cohort_time) / ?)::int AS bucket,
154
+ COUNT(DISTINCT query.user_id)
155
+ FROM cohorts INNER JOIN query ON query.user_id = cohorts.user_id
156
+ WHERE query.conversion_time IS NOT NULL
157
+ AND query.conversion_time >= cohorts.cohort_time
158
+ #{cohort_column == "conversion_time" ? "AND query.conversion_time != cohorts.cohort_time" : ""}
159
+ GROUP BY 1, 2
160
+ SQL
161
+ tzname = Blazer.time_zone.tzinfo.name
162
+ params = [statement, period, tzname, period, tzname, days.to_i * 86400]
163
+ connection_model.send(:sanitize_sql_array, params)
164
+ end
165
+
125
166
  protected
126
167
 
127
168
  def select_all(statement, params = [])