blazer 2.2.7 → 2.4.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +119 -167
  4. data/app/assets/stylesheets/blazer/application.css +4 -0
  5. data/app/controllers/blazer/base_controller.rb +8 -0
  6. data/app/controllers/blazer/dashboards_controller.rb +3 -1
  7. data/app/controllers/blazer/queries_controller.rb +119 -3
  8. data/app/controllers/blazer/uploads_controller.rb +147 -0
  9. data/app/models/blazer/query.rb +9 -2
  10. data/app/models/blazer/upload.rb +11 -0
  11. data/app/models/blazer/uploads_connection.rb +7 -0
  12. data/app/views/blazer/_nav.html.erb +3 -0
  13. data/app/views/blazer/checks/_form.html.erb +1 -1
  14. data/app/views/blazer/checks/index.html.erb +3 -0
  15. data/app/views/blazer/dashboards/_form.html.erb +1 -1
  16. data/app/views/blazer/dashboards/show.html.erb +1 -1
  17. data/app/views/blazer/queries/_caching.html.erb +16 -0
  18. data/app/views/blazer/queries/_cohorts.html.erb +48 -0
  19. data/app/views/blazer/queries/docs.html.erb +6 -0
  20. data/app/views/blazer/queries/home.html.erb +3 -0
  21. data/app/views/blazer/queries/run.html.erb +15 -17
  22. data/app/views/blazer/queries/show.html.erb +1 -1
  23. data/app/views/blazer/uploads/_form.html.erb +27 -0
  24. data/app/views/blazer/uploads/edit.html.erb +3 -0
  25. data/app/views/blazer/uploads/index.html.erb +55 -0
  26. data/app/views/blazer/uploads/new.html.erb +3 -0
  27. data/config/routes.rb +5 -0
  28. data/lib/blazer.rb +22 -0
  29. data/lib/blazer/adapters/base_adapter.rb +8 -0
  30. data/lib/blazer/adapters/hive_adapter.rb +45 -0
  31. data/lib/blazer/adapters/spark_adapter.rb +9 -0
  32. data/lib/blazer/adapters/sql_adapter.rb +64 -1
  33. data/lib/blazer/data_source.rb +1 -1
  34. data/lib/blazer/version.rb +1 -1
  35. data/lib/generators/blazer/templates/config.yml.tt +6 -0
  36. data/lib/generators/blazer/templates/install.rb.tt +1 -0
  37. data/lib/generators/blazer/templates/uploads.rb.tt +10 -0
  38. data/lib/generators/blazer/uploads_generator.rb +18 -0
  39. data/lib/tasks/blazer.rake +9 -0
  40. metadata +20 -7
@@ -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: @statements[i], 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 %>
@@ -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
@@ -18,12 +18,14 @@ require "blazer/adapters/cassandra_adapter"
18
18
  require "blazer/adapters/drill_adapter"
19
19
  require "blazer/adapters/druid_adapter"
20
20
  require "blazer/adapters/elasticsearch_adapter"
21
+ require "blazer/adapters/hive_adapter"
21
22
  require "blazer/adapters/influxdb_adapter"
22
23
  require "blazer/adapters/mongodb_adapter"
23
24
  require "blazer/adapters/neo4j_adapter"
24
25
  require "blazer/adapters/presto_adapter"
25
26
  require "blazer/adapters/salesforce_adapter"
26
27
  require "blazer/adapters/soda_adapter"
28
+ require "blazer/adapters/spark_adapter"
27
29
  require "blazer/adapters/sql_adapter"
28
30
  require "blazer/adapters/snowflake_adapter"
29
31
 
@@ -32,6 +34,7 @@ require "blazer/engine"
32
34
 
33
35
  module Blazer
34
36
  class Error < StandardError; end
37
+ class UploadError < Error; end
35
38
  class TimeoutNotSupported < Error; end
36
39
 
37
40
  class << self
@@ -206,6 +209,23 @@ module Blazer
206
209
  slack_webhook_url.present?
207
210
  end
208
211
 
212
+ def self.uploads?
213
+ settings.key?("uploads")
214
+ end
215
+
216
+ def self.uploads_connection
217
+ raise "Empty url for uploads" unless settings.dig("uploads", "url")
218
+ Blazer::UploadsConnection.connection
219
+ end
220
+
221
+ def self.uploads_schema
222
+ settings.dig("uploads", "schema") || "uploads"
223
+ end
224
+
225
+ def self.uploads_table_name(name)
226
+ uploads_connection.quote_table_name("#{uploads_schema}.#{name}")
227
+ end
228
+
209
229
  def self.adapters
210
230
  @adapters ||= {}
211
231
  end
@@ -221,11 +241,13 @@ Blazer.register_adapter "cassandra", Blazer::Adapters::CassandraAdapter
221
241
  Blazer.register_adapter "drill", Blazer::Adapters::DrillAdapter
222
242
  Blazer.register_adapter "druid", Blazer::Adapters::DruidAdapter
223
243
  Blazer.register_adapter "elasticsearch", Blazer::Adapters::ElasticsearchAdapter
244
+ Blazer.register_adapter "hive", Blazer::Adapters::HiveAdapter
224
245
  Blazer.register_adapter "influxdb", Blazer::Adapters::InfluxdbAdapter
225
246
  Blazer.register_adapter "neo4j", Blazer::Adapters::Neo4jAdapter
226
247
  Blazer.register_adapter "presto", Blazer::Adapters::PrestoAdapter
227
248
  Blazer.register_adapter "mongodb", Blazer::Adapters::MongodbAdapter
228
249
  Blazer.register_adapter "salesforce", Blazer::Adapters::SalesforceAdapter
229
250
  Blazer.register_adapter "soda", Blazer::Adapters::SodaAdapter
251
+ Blazer.register_adapter "spark", Blazer::Adapters::SparkAdapter
230
252
  Blazer.register_adapter "sql", Blazer::Adapters::SqlAdapter
231
253
  Blazer.register_adapter "snowflake", Blazer::Adapters::SnowflakeAdapter
@@ -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
@@ -0,0 +1,45 @@
1
+ module Blazer
2
+ module Adapters
3
+ class HiveAdapter < BaseAdapter
4
+ def run_statement(statement, comment)
5
+ columns = []
6
+ rows = []
7
+ error = nil
8
+
9
+ begin
10
+ result = client.execute("#{statement} /*#{comment}*/")
11
+ columns = result.any? ? result.first.keys : []
12
+ rows = result.map(&:values)
13
+ rescue => e
14
+ error = e.message
15
+ end
16
+
17
+ [columns, rows, error]
18
+ end
19
+
20
+ def tables
21
+ client.execute("SHOW TABLES").map { |r| r["tab_name"] }
22
+ end
23
+
24
+ def preview_statement
25
+ "SELECT * FROM {table} LIMIT 10"
26
+ end
27
+
28
+ protected
29
+
30
+ def client
31
+ @client ||= begin
32
+ uri = URI.parse(settings["url"])
33
+ Hexspace::Client.new(
34
+ host: uri.host,
35
+ port: uri.port,
36
+ username: uri.user,
37
+ password: uri.password,
38
+ database: uri.path.sub(/\A\//, ""),
39
+ mode: uri.scheme.to_sym
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ module Blazer
2
+ module Adapters
3
+ class SparkAdapter < HiveAdapter
4
+ def tables
5
+ client.execute("SHOW TABLES").map { |r| r["tableName"] }
6
+ end
7
+ end
8
+ end
9
+ end
@@ -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,67 @@ 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? || mysql?
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
+ tzname = Blazer.time_zone.tzinfo.name
135
+
136
+ if mysql?
137
+ time_sql = "CONVERT_TZ(cohorts.cohort_time, '+00:00', ?)"
138
+ case period
139
+ when "day"
140
+ date_sql = "CAST(DATE_FORMAT(#{time_sql}, '%Y-%m-%d') AS DATE)"
141
+ date_params = [tzname]
142
+ when "week"
143
+ date_sql = "CAST(DATE_FORMAT(#{time_sql} - INTERVAL ((5 + DAYOFWEEK(#{time_sql})) % 7) DAY, '%Y-%m-%d') AS DATE)"
144
+ date_params = [tzname, tzname]
145
+ else
146
+ date_sql = "CAST(DATE_FORMAT(#{time_sql}, '%Y-%m-01') AS DATE)"
147
+ date_params = [tzname]
148
+ end
149
+ bucket_sql = "CAST(CEIL(TIMESTAMPDIFF(SECOND, cohorts.cohort_time, query.conversion_time) / ?) AS INTEGER)"
150
+ else
151
+ date_sql = "date_trunc(?, cohorts.cohort_time::timestamptz AT TIME ZONE ?)::date"
152
+ date_params = [period, tzname]
153
+ bucket_sql = "CEIL(EXTRACT(EPOCH FROM query.conversion_time - cohorts.cohort_time) / ?)::int"
154
+ end
155
+
156
+ # WITH not an optimization fence in Postgres 12+
157
+ statement = <<~SQL
158
+ WITH query AS (
159
+ #{statement}
160
+ ),
161
+ cohorts AS (
162
+ SELECT user_id, MIN(#{cohort_column}) AS cohort_time FROM query
163
+ WHERE user_id IS NOT NULL AND #{cohort_column} IS NOT NULL
164
+ GROUP BY 1
165
+ )
166
+ SELECT
167
+ #{date_sql} AS period,
168
+ 0 AS bucket,
169
+ COUNT(DISTINCT cohorts.user_id)
170
+ FROM cohorts GROUP BY 1
171
+ UNION ALL
172
+ SELECT
173
+ #{date_sql} AS period,
174
+ #{bucket_sql} AS bucket,
175
+ COUNT(DISTINCT query.user_id)
176
+ FROM cohorts INNER JOIN query ON query.user_id = cohorts.user_id
177
+ WHERE query.conversion_time IS NOT NULL
178
+ AND query.conversion_time >= cohorts.cohort_time
179
+ #{cohort_column == "conversion_time" ? "AND query.conversion_time != cohorts.cohort_time" : ""}
180
+ GROUP BY 1, 2
181
+ SQL
182
+ params = [statement] + date_params + date_params + [days.to_i * 86400]
183
+ connection_model.send(:sanitize_sql_array, params)
184
+ end
185
+
125
186
  protected
126
187
 
127
188
  def select_all(statement, params = [])
@@ -165,6 +226,8 @@ module Blazer
165
226
  "public"
166
227
  elsif sqlserver?
167
228
  "dbo"
229
+ elsif connection_model.respond_to?(:connection_db_config)
230
+ connection_model.connection_db_config.database
168
231
  else
169
232
  connection_model.connection_config[:database]
170
233
  end