sage-rails 0.0.3
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.
- checksums.yaml +7 -0
- data/README.md +202 -0
- data/app/assets/images/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/chevron-right.svg +1 -0
- data/app/assets/images/loading.svg +4 -0
- data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
- data/app/assets/images/sage/chevron-right.svg +1 -0
- data/app/assets/images/sage/loading.svg +4 -0
- data/app/assets/javascripts/sage/application.js +18 -0
- data/app/assets/stylesheets/sage/application.css +308 -0
- data/app/controllers/sage/actions_controller.rb +5 -0
- data/app/controllers/sage/application_controller.rb +4 -0
- data/app/controllers/sage/base_controller.rb +10 -0
- data/app/controllers/sage/checks_controller.rb +65 -0
- data/app/controllers/sage/dashboards_controller.rb +130 -0
- data/app/controllers/sage/queries/messages_controller.rb +62 -0
- data/app/controllers/sage/queries_controller.rb +596 -0
- data/app/helpers/sage/application_helper.rb +30 -0
- data/app/helpers/sage/queries_helper.rb +23 -0
- data/app/javascript/controllers/element_removal_controller.js +7 -0
- data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
- data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
- data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
- data/app/javascript/sage/controllers/search_controller.js +47 -0
- data/app/javascript/sage/controllers/select_controller.js +215 -0
- data/app/javascript/sage.js +19 -0
- data/app/jobs/sage/application_job.rb +4 -0
- data/app/jobs/sage/process_report_job.rb +80 -0
- data/app/mailers/sage/application_mailer.rb +6 -0
- data/app/models/sage/application_record.rb +5 -0
- data/app/models/sage/message.rb +8 -0
- data/app/schemas/sage/report_response_schema.rb +8 -0
- data/app/views/layouts/application.html.erb +34 -0
- data/app/views/layouts/sage/application.html.erb +94 -0
- data/app/views/sage/checks/_form.html.erb +81 -0
- data/app/views/sage/checks/_search.html.erb +8 -0
- data/app/views/sage/checks/edit.html.erb +10 -0
- data/app/views/sage/checks/index.html.erb +58 -0
- data/app/views/sage/checks/new.html.erb +8 -0
- data/app/views/sage/dashboards/_form.html.erb +50 -0
- data/app/views/sage/dashboards/_search.html.erb +8 -0
- data/app/views/sage/dashboards/index.html.erb +58 -0
- data/app/views/sage/dashboards/new.html.erb +8 -0
- data/app/views/sage/dashboards/show.html.erb +58 -0
- data/app/views/sage/messages/_form.html.erb +14 -0
- data/app/views/sage/queries/_caching.html.erb +17 -0
- data/app/views/sage/queries/_form.html.erb +72 -0
- data/app/views/sage/queries/_input.html.erb +17 -0
- data/app/views/sage/queries/_message.html.erb +25 -0
- data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
- data/app/views/sage/queries/_new_form.html.erb +43 -0
- data/app/views/sage/queries/_run.html.erb +232 -0
- data/app/views/sage/queries/_search.html.erb +8 -0
- data/app/views/sage/queries/_statement_box.html.erb +241 -0
- data/app/views/sage/queries/_streaming_message.html.erb +14 -0
- data/app/views/sage/queries/create.turbo_stream.erb +114 -0
- data/app/views/sage/queries/edit.html.erb +48 -0
- data/app/views/sage/queries/index.html.erb +59 -0
- data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
- data/app/views/sage/queries/messages/index.html.erb +44 -0
- data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
- data/app/views/sage/queries/new.html.erb +195 -0
- data/app/views/sage/queries/run.html.erb +1 -0
- data/app/views/sage/queries/run.turbo_stream.erb +3 -0
- data/app/views/sage/queries/show.html.erb +49 -0
- data/app/views/sage/queries/table_schema.html.erb +77 -0
- data/app/views/sage/shared/_navigation.html.erb +26 -0
- data/app/views/sage/shared/_overlay.html.erb +11 -0
- data/config/importmap.rb +11 -0
- data/config/initializers/pagy.rb +2 -0
- data/config/initializers/ransack.rb +152 -0
- data/config/routes.rb +31 -0
- data/lib/generators/sage/USAGE +13 -0
- data/lib/generators/sage/install/install_generator.rb +128 -0
- data/lib/generators/sage/install/templates/sage.rb +22 -0
- data/lib/sage/database_schema_context.rb +56 -0
- data/lib/sage/engine.rb +260 -0
- data/lib/sage/model_scopes_context.rb +185 -0
- data/lib/sage/report_processor.rb +263 -0
- data/lib/sage/version.rb +3 -0
- data/lib/sage.rb +25 -0
- data/lib/tasks/sage_tasks.rake +4 -0
- metadata +245 -0
@@ -0,0 +1,596 @@
|
|
1
|
+
module Sage
|
2
|
+
class QueriesController < BaseController
|
3
|
+
before_action :set_query, only: [ :show, :edit, :update, :destroy, :refresh, :run ]
|
4
|
+
before_action :set_data_source, only: [ :tables, :docs, :schema, :cancel ]
|
5
|
+
|
6
|
+
def index
|
7
|
+
@q = Blazer::Query.ransack(params[:q])
|
8
|
+
@queries = @q.result.named.active
|
9
|
+
|
10
|
+
# Only include creator if Blazer.user_class is configured
|
11
|
+
@queries = @queries.includes(:creator) if Blazer.user_class
|
12
|
+
|
13
|
+
@queries = @queries.order(:name)
|
14
|
+
|
15
|
+
# Apply additional filters if needed
|
16
|
+
if blazer_user && params[:filter] == "mine"
|
17
|
+
@queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
|
18
|
+
elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
|
19
|
+
query_ids = Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq
|
20
|
+
@queries = @queries.where(id: query_ids)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Filter out private queries (starting with #) unless they belong to the current user
|
24
|
+
@queries = @queries.where("name NOT LIKE ? OR creator_id = ?", "#%", blazer_user.try(:id))
|
25
|
+
|
26
|
+
# Apply pagination with Pagy
|
27
|
+
@pagy, @queries = pagy(@queries)
|
28
|
+
end
|
29
|
+
|
30
|
+
def new
|
31
|
+
@query = Blazer::Query.new(
|
32
|
+
data_source: params[:data_source],
|
33
|
+
name: params[:name]
|
34
|
+
)
|
35
|
+
if params[:fork_query_id]
|
36
|
+
@query.statement ||= Blazer::Query.find(params[:fork_query_id]).try(:statement)
|
37
|
+
end
|
38
|
+
if params[:upload_id]
|
39
|
+
upload = Blazer::Upload.find(params[:upload_id])
|
40
|
+
upload_settings = Blazer.settings["uploads"]
|
41
|
+
@query.data_source ||= upload_settings["data_source"]
|
42
|
+
@query.statement ||= "SELECT * FROM #{upload.table_name} LIMIT 10"
|
43
|
+
end
|
44
|
+
|
45
|
+
# Get schema information for the current data source
|
46
|
+
data_source_key = @query.data_source || Blazer.data_sources.keys.first
|
47
|
+
@data_source = Blazer.data_sources[data_source_key]
|
48
|
+
if @data_source
|
49
|
+
schema = @data_source.schema
|
50
|
+
# Filter out internal/system tables that aren't relevant for users
|
51
|
+
@schema = schema.reject do |table_info|
|
52
|
+
table_name = table_info[:table].to_s.downcase
|
53
|
+
table_name.start_with?("sage_", "blazer_") ||
|
54
|
+
%w[ar_internal_metadata schema_migrations sqlite_sequence].include?(table_name)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def create
|
60
|
+
# Handle the new Sage form submission with question parameter
|
61
|
+
if params[:query][:question].present?
|
62
|
+
question = params[:query][:question]
|
63
|
+
|
64
|
+
# Create query with placeholder name and statement
|
65
|
+
@query = Blazer::Query.new(
|
66
|
+
name: "Sage Query - #{Time.current.strftime('%Y-%m-%d %H:%M')}",
|
67
|
+
statement: "-- Processing your question...\n-- #{question}",
|
68
|
+
data_source: Blazer.data_sources.keys.first
|
69
|
+
)
|
70
|
+
@query.creator = blazer_user if @query.respond_to?(:creator)
|
71
|
+
@query.status = "active" if @query.respond_to?(:status)
|
72
|
+
|
73
|
+
if @query.save
|
74
|
+
# Create associated Sage::Message record with the user's question
|
75
|
+
message = @query.messages.create!(
|
76
|
+
body: question,
|
77
|
+
creator: (blazer_user if ::Blazer.user_class)
|
78
|
+
)
|
79
|
+
|
80
|
+
# Generate a unique stream target ID for real-time updates
|
81
|
+
stream_target_id = "message_#{SecureRandom.hex(8)}"
|
82
|
+
|
83
|
+
# Kick off the ProcessReportJob with 1-second delay
|
84
|
+
Sage::ProcessReportJob.set(wait: 1.second).perform_later(
|
85
|
+
question,
|
86
|
+
query_id: @query.id,
|
87
|
+
stream_target_id: stream_target_id
|
88
|
+
)
|
89
|
+
|
90
|
+
redirect_to edit_query_path(@query)
|
91
|
+
else
|
92
|
+
render_errors @query
|
93
|
+
end
|
94
|
+
else
|
95
|
+
# Handle traditional Blazer query creation
|
96
|
+
@query = Blazer::Query.new(query_params)
|
97
|
+
@query.creator = blazer_user if @query.respond_to?(:creator)
|
98
|
+
@query.status = "active" if @query.respond_to?(:status)
|
99
|
+
|
100
|
+
if @query.save
|
101
|
+
redirect_to query_path(@query, params: variable_params(@query))
|
102
|
+
else
|
103
|
+
render_errors @query
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def show
|
109
|
+
@statement = @query.statement_object
|
110
|
+
@success = process_vars(@statement)
|
111
|
+
|
112
|
+
@smart_vars = {}
|
113
|
+
@sql_errors = []
|
114
|
+
@bind_vars.each do |var|
|
115
|
+
smart_var, error = parse_smart_variables(var, @statement.data_source)
|
116
|
+
@smart_vars[var] = smart_var if smart_var
|
117
|
+
@sql_errors << error if error
|
118
|
+
end
|
119
|
+
|
120
|
+
@query.update!(status: "active") if @query.respond_to?(:status) && @query.status.in?([ "archived", nil ])
|
121
|
+
|
122
|
+
add_cohort_analysis_vars if @query.cohort_analysis?
|
123
|
+
|
124
|
+
if @success
|
125
|
+
@run_data = { statement: @query.statement, query_id: @query.id, data_source: @query.data_source, variables: variable_params(@query) }
|
126
|
+
@run_data[:forecast] = "t" if params[:forecast]
|
127
|
+
@run_data[:cohort_period] = params[:cohort_period] if params[:cohort_period]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def edit
|
132
|
+
# Messages will be loaded via turbo_frame from messages#index
|
133
|
+
end
|
134
|
+
|
135
|
+
def run
|
136
|
+
# @query is set by before_action for member routes (GET /queries/:id/run)
|
137
|
+
# For collection routes (POST /queries/run), load query if query_id is provided
|
138
|
+
@query ||= Blazer::Query.find_by(id: params[:query_id]) if params[:query_id]
|
139
|
+
|
140
|
+
# use query data source when present
|
141
|
+
data_source = @query.data_source if @query && @query.data_source
|
142
|
+
data_source ||= params[:data_source]
|
143
|
+
@data_source = Blazer.data_sources[data_source]
|
144
|
+
|
145
|
+
# Prefer params statement over query's saved statement (for live editing)
|
146
|
+
statement = params[:statement].presence || @query&.statement
|
147
|
+
@statement = Blazer::Statement.new(statement, @data_source)
|
148
|
+
# before process_vars
|
149
|
+
@cohort_analysis = @statement.cohort_analysis?
|
150
|
+
|
151
|
+
# fallback for now for users with open tabs
|
152
|
+
# TODO remove fallback in future version
|
153
|
+
@var_params = request.request_parameters["variables"] || request.request_parameters
|
154
|
+
@success = process_vars(@statement, @var_params)
|
155
|
+
@only_chart = params[:only_chart]
|
156
|
+
@run_id = blazer_params[:run_id]
|
157
|
+
|
158
|
+
run_cohort_analysis if @cohort_analysis
|
159
|
+
|
160
|
+
query_running = !@run_id.nil?
|
161
|
+
|
162
|
+
if query_running
|
163
|
+
@timestamp = blazer_params[:timestamp].to_i
|
164
|
+
|
165
|
+
@result = @data_source.run_results(@run_id)
|
166
|
+
@success = !@result.nil?
|
167
|
+
|
168
|
+
if @success
|
169
|
+
@data_source.delete_results(@run_id)
|
170
|
+
@columns = @result.columns
|
171
|
+
@rows = @result.rows
|
172
|
+
@error = @result.error
|
173
|
+
@just_cached = !@result.error && @result.cached_at.present?
|
174
|
+
@cached_at = nil
|
175
|
+
params[:data_source] = nil
|
176
|
+
render_run
|
177
|
+
elsif Time.now > Time.at(@timestamp + (@data_source.timeout || 600).to_i + 5)
|
178
|
+
# query lost
|
179
|
+
Rails.logger.info "[blazer lost query] #{@run_id}"
|
180
|
+
@error = "We lost your query :("
|
181
|
+
@rows = []
|
182
|
+
@columns = []
|
183
|
+
render_run
|
184
|
+
else
|
185
|
+
continue_run
|
186
|
+
end
|
187
|
+
elsif @success
|
188
|
+
@run_id = blazer_run_id
|
189
|
+
|
190
|
+
async = Blazer.async
|
191
|
+
|
192
|
+
options = { user: blazer_user, query: @query, refresh_cache: params[:check], run_id: @run_id, async: async }
|
193
|
+
if async && request.format.symbol != :csv
|
194
|
+
Blazer::RunStatementJob.perform_later(@data_source.id, @statement.statement, options.merge(values: @statement.values))
|
195
|
+
wait_start = Blazer.monotonic_time
|
196
|
+
loop do
|
197
|
+
sleep(0.1)
|
198
|
+
@result = @data_source.run_results(@run_id)
|
199
|
+
break if @result || Blazer.monotonic_time - wait_start > 3
|
200
|
+
end
|
201
|
+
else
|
202
|
+
@result = Blazer::RunStatement.new.perform(@statement, options)
|
203
|
+
end
|
204
|
+
|
205
|
+
if @result
|
206
|
+
@data_source.delete_results(@run_id) if @run_id && async
|
207
|
+
|
208
|
+
@columns = @result.columns
|
209
|
+
@rows = @result.rows
|
210
|
+
@error = @result.error
|
211
|
+
@cached_at = @result.cached_at
|
212
|
+
@just_cached = @result.just_cached
|
213
|
+
|
214
|
+
@forecast = @query && @result.forecastable? && params[:forecast]
|
215
|
+
if @forecast
|
216
|
+
@result.forecast
|
217
|
+
@forecast_error = @result.forecast_error
|
218
|
+
@forecast = @forecast_error.nil?
|
219
|
+
end
|
220
|
+
|
221
|
+
render_run
|
222
|
+
else
|
223
|
+
@timestamp = Time.now.to_i
|
224
|
+
continue_run
|
225
|
+
end
|
226
|
+
else
|
227
|
+
render layout: false
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def refresh
|
232
|
+
refresh_query(@query)
|
233
|
+
redirect_to query_path(@query, params: variable_params(@query))
|
234
|
+
end
|
235
|
+
|
236
|
+
def update
|
237
|
+
if params[:commit] == "Fork"
|
238
|
+
@query = Blazer::Query.new
|
239
|
+
@query.creator = blazer_user if @query.respond_to?(:creator)
|
240
|
+
end
|
241
|
+
@query.status = "active" if @query.respond_to?(:status)
|
242
|
+
unless @query.editable?(blazer_user)
|
243
|
+
@query.errors.add(:base, "Sorry, permission denied")
|
244
|
+
end
|
245
|
+
if @query.errors.empty? && @query.update(query_params)
|
246
|
+
redirect_to query_path(@query, params: variable_params(@query))
|
247
|
+
else
|
248
|
+
render_errors @query
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def destroy
|
253
|
+
@query.destroy if @query.editable?(blazer_user)
|
254
|
+
redirect_to root_path
|
255
|
+
end
|
256
|
+
|
257
|
+
def tables
|
258
|
+
render json: @data_source.tables
|
259
|
+
end
|
260
|
+
|
261
|
+
def docs
|
262
|
+
@smart_variables = @data_source.smart_variables
|
263
|
+
@linked_columns = @data_source.linked_columns
|
264
|
+
@smart_columns = @data_source.smart_columns
|
265
|
+
end
|
266
|
+
|
267
|
+
def schema
|
268
|
+
@schema = @data_source.schema
|
269
|
+
end
|
270
|
+
|
271
|
+
def table_schema
|
272
|
+
table_name = params[:table_name]
|
273
|
+
data_source_key = params[:data_source] || Blazer.data_sources.keys.first
|
274
|
+
@data_source = Blazer.data_sources[data_source_key]
|
275
|
+
|
276
|
+
if @data_source && table_name.present?
|
277
|
+
schema = @data_source.schema
|
278
|
+
@table_info = schema.find { |table| table[:table] == table_name }
|
279
|
+
@table_display_name = table_name.to_s.gsub("_", " ").titleize
|
280
|
+
end
|
281
|
+
|
282
|
+
render layout: false
|
283
|
+
end
|
284
|
+
|
285
|
+
def cancel
|
286
|
+
@data_source.cancel(blazer_run_id)
|
287
|
+
head :ok
|
288
|
+
end
|
289
|
+
|
290
|
+
private
|
291
|
+
|
292
|
+
def set_data_source
|
293
|
+
@data_source = Blazer.data_sources[params[:data_source]]
|
294
|
+
rescue Blazer::Error => e
|
295
|
+
raise unless e.message.start_with?("Unknown data source:")
|
296
|
+
render plain: "Unknown data source", status: :not_found
|
297
|
+
end
|
298
|
+
|
299
|
+
def continue_run
|
300
|
+
render json: { run_id: @run_id, timestamp: @timestamp }, status: :accepted
|
301
|
+
end
|
302
|
+
|
303
|
+
def render_run
|
304
|
+
@checks = @query ? @query.checks.order(:id) : []
|
305
|
+
|
306
|
+
@first_row = @rows.first || []
|
307
|
+
@column_types = []
|
308
|
+
if @rows.any?
|
309
|
+
@columns.each_with_index do |_, i|
|
310
|
+
@column_types << (
|
311
|
+
case @first_row[i]
|
312
|
+
when Integer
|
313
|
+
"int"
|
314
|
+
when Float, BigDecimal
|
315
|
+
"float"
|
316
|
+
else
|
317
|
+
"string-ins"
|
318
|
+
end
|
319
|
+
)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
@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)
|
324
|
+
|
325
|
+
@smart_values = @result.smart_values if @result
|
326
|
+
|
327
|
+
@linked_columns = @data_source.linked_columns
|
328
|
+
|
329
|
+
@markers = []
|
330
|
+
@geojson = []
|
331
|
+
set_map_data if Blazer.maps?
|
332
|
+
|
333
|
+
render_cohort_analysis if @cohort_analysis && !@error
|
334
|
+
|
335
|
+
respond_to do |format|
|
336
|
+
format.html do
|
337
|
+
render layout: false
|
338
|
+
end
|
339
|
+
format.turbo_stream
|
340
|
+
format.csv do
|
341
|
+
# not ideal, but useful for testing
|
342
|
+
raise Error, @error if @error && Rails.env.test?
|
343
|
+
|
344
|
+
data = csv_data(@columns, @rows, @data_source)
|
345
|
+
filename = "#{@query.try(:name).try(:parameterize).presence || 'query'}.csv"
|
346
|
+
send_data data, type: "text/csv; charset=utf-8", disposition: "attachment", filename: filename
|
347
|
+
end
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
def set_map_data
|
352
|
+
[ [ "latitude", "longitude" ], [ "lat", "lon" ], [ "lat", "lng" ] ].each do |keys|
|
353
|
+
lat_index = @columns.index(keys.first)
|
354
|
+
lon_index = @columns.index(keys.last)
|
355
|
+
if lat_index && lon_index
|
356
|
+
@markers =
|
357
|
+
@rows.select do |r|
|
358
|
+
r[lat_index] && r[lon_index]
|
359
|
+
end.map do |r|
|
360
|
+
{
|
361
|
+
tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == lat_index || i == lon_index }),
|
362
|
+
latitude: r[lat_index],
|
363
|
+
longitude: r[lon_index]
|
364
|
+
}
|
365
|
+
end
|
366
|
+
|
367
|
+
return if @markers.any?
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
geo_index = @columns.index("geojson")
|
372
|
+
if geo_index
|
373
|
+
@geojson =
|
374
|
+
@rows.filter_map do |r|
|
375
|
+
if r[geo_index].is_a?(String) && (geometry = (JSON.parse(r[geo_index]) rescue nil)) && geometry.is_a?(Hash)
|
376
|
+
{
|
377
|
+
tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == geo_index }),
|
378
|
+
geometry: geometry
|
379
|
+
}
|
380
|
+
end
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
def map_tooltip(r)
|
386
|
+
r.map { |v, i| "<strong>#{ERB::Util.html_escape(@columns[i])}:</strong> #{ERB::Util.html_escape(v)}" }.join("<br>").truncate(140, separator: " ")
|
387
|
+
end
|
388
|
+
|
389
|
+
def set_queries(limit = nil)
|
390
|
+
@queries = Blazer::Query.named.select(:id, :name, :creator_id, :statement)
|
391
|
+
@queries = @queries.includes(:creator) if Blazer.user_class
|
392
|
+
|
393
|
+
if blazer_user && params[:filter] == "mine"
|
394
|
+
@queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
|
395
|
+
elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
|
396
|
+
@queries = queries_by_ids(Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq)
|
397
|
+
else
|
398
|
+
@queries = @queries.limit(limit) if limit
|
399
|
+
@queries = @queries.active.order(:name)
|
400
|
+
end
|
401
|
+
@queries = @queries.to_a
|
402
|
+
|
403
|
+
@more = limit && @queries.size >= limit
|
404
|
+
|
405
|
+
@queries = @queries.select { |q| !q.name.to_s.start_with?("#") || q.try(:creator).try(:id) == blazer_user.try(:id) }
|
406
|
+
end
|
407
|
+
|
408
|
+
def queries_by_ids(favorite_query_ids)
|
409
|
+
queries = Blazer::Query.active.named.where(id: favorite_query_ids)
|
410
|
+
queries = queries.includes(:creator) if Blazer.user_class
|
411
|
+
queries = queries.index_by(&:id)
|
412
|
+
favorite_query_ids.map { |query_id| queries[query_id] }.compact
|
413
|
+
end
|
414
|
+
|
415
|
+
def set_query
|
416
|
+
@query = Blazer::Query.find_by(id: params[:id].to_s.split("-").first) if params[:id]
|
417
|
+
end
|
418
|
+
|
419
|
+
def render_forbidden
|
420
|
+
render plain: "Access denied", status: :forbidden
|
421
|
+
end
|
422
|
+
|
423
|
+
def query_params
|
424
|
+
params.require(:query).permit(:name, :description, :statement, :data_source, :question)
|
425
|
+
end
|
426
|
+
|
427
|
+
def blazer_params
|
428
|
+
params[:blazer] || {}
|
429
|
+
end
|
430
|
+
|
431
|
+
def csv_data(columns, rows, data_source)
|
432
|
+
CSV.generate do |csv|
|
433
|
+
csv << columns
|
434
|
+
rows.each do |row|
|
435
|
+
csv << row.each_with_index.map { |v, i| v.is_a?(Time) ? blazer_time_value(data_source, columns[i], v) : v }
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
def blazer_time_value(data_source, k, v)
|
441
|
+
data_source.local_time_suffix.any? { |s| k.ends_with?(s) } ? v.to_s.sub(" UTC", "") : v.in_time_zone(Blazer.time_zone)
|
442
|
+
end
|
443
|
+
helper_method :blazer_time_value
|
444
|
+
|
445
|
+
def blazer_run_id
|
446
|
+
params[:run_id].to_s.gsub(/[^a-z0-9\-]/i, "")
|
447
|
+
end
|
448
|
+
|
449
|
+
def run_cohort_analysis
|
450
|
+
unless @statement.data_source.supports_cohort_analysis?
|
451
|
+
@cohort_error = "This data source does not support cohort analysis"
|
452
|
+
end
|
453
|
+
|
454
|
+
@show_cohort_rows = !params[:query_id] || @cohort_error
|
455
|
+
cohort_analysis_statement(@statement) unless @show_cohort_rows
|
456
|
+
end
|
457
|
+
|
458
|
+
def render_cohort_analysis
|
459
|
+
if @show_cohort_rows
|
460
|
+
@cohort_analysis = false
|
461
|
+
|
462
|
+
@row_limit = 1000
|
463
|
+
|
464
|
+
# check results
|
465
|
+
unless @cohort_error
|
466
|
+
# check names
|
467
|
+
expected_columns = [ "user_id", "conversion_time" ]
|
468
|
+
missing_columns = expected_columns - @result.columns
|
469
|
+
if missing_columns.any?
|
470
|
+
@cohort_error = "Expected user_id and conversion_time columns"
|
471
|
+
end
|
472
|
+
|
473
|
+
# check types (user_id can be any type)
|
474
|
+
unless @cohort_error
|
475
|
+
column_types = @result.columns.zip(@result.column_types).to_h
|
476
|
+
|
477
|
+
if !column_types["cohort_time"].in?([ "time", nil ])
|
478
|
+
@cohort_error = "cohort_time must be time column"
|
479
|
+
elsif !column_types["conversion_time"].in?([ "time", nil ])
|
480
|
+
@cohort_error = "conversion_time must be time column"
|
481
|
+
end
|
482
|
+
end
|
483
|
+
end
|
484
|
+
else
|
485
|
+
@today = Blazer.time_zone.today
|
486
|
+
@min_cohort_date, @max_cohort_date = @result.rows.map { |r| r[0] }.minmax
|
487
|
+
@buckets = {}
|
488
|
+
@rows.each do |r|
|
489
|
+
@buckets[[ r[0], r[1] ]] = r[2]
|
490
|
+
end
|
491
|
+
|
492
|
+
@cohort_dates = []
|
493
|
+
current_date = @max_cohort_date
|
494
|
+
while current_date && current_date >= @min_cohort_date
|
495
|
+
@cohort_dates << current_date
|
496
|
+
current_date =
|
497
|
+
case @cohort_period
|
498
|
+
when "day"
|
499
|
+
current_date - 1
|
500
|
+
when "week"
|
501
|
+
current_date - 7
|
502
|
+
else
|
503
|
+
current_date.prev_month
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
num_cols = @cohort_dates.size
|
508
|
+
@columns = [ "Cohort", "Users" ] + num_cols.times.map { |i| "#{@conversion_period.titleize} #{i + 1}" }
|
509
|
+
rows = []
|
510
|
+
date_format = @cohort_period == "month" ? "%b %Y" : "%b %-e, %Y"
|
511
|
+
@cohort_dates.each do |date|
|
512
|
+
row = [ date.strftime(date_format), @buckets[[ date, 0 ]] || 0 ]
|
513
|
+
|
514
|
+
num_cols.times do |i|
|
515
|
+
if @today >= date + (@cohort_days * i)
|
516
|
+
row << (@buckets[[ date, i + 1 ]] || 0)
|
517
|
+
end
|
518
|
+
end
|
519
|
+
|
520
|
+
rows << row
|
521
|
+
end
|
522
|
+
@rows = rows
|
523
|
+
end
|
524
|
+
end
|
525
|
+
end
|
526
|
+
end
|
527
|
+
#
|
528
|
+
# module Sage
|
529
|
+
# class QueriesController < ApplicationController
|
530
|
+
# before_action :set_data_source
|
531
|
+
#
|
532
|
+
# def new
|
533
|
+
# @query = Blazer::Query.new
|
534
|
+
# end
|
535
|
+
#
|
536
|
+
# def create
|
537
|
+
# @query = Blazer::Query.new(name: "Sage Query: #{Time.current}")
|
538
|
+
# @query.statement = generate_sql_from_question(query_params[:question])
|
539
|
+
# @query.creator = blazer_user if defined?(blazer_user)
|
540
|
+
#
|
541
|
+
# # Optionally save the query if configured
|
542
|
+
# if Sage.configuration.auto_save_queries && @query.statement.present?
|
543
|
+
# @query.save
|
544
|
+
# end
|
545
|
+
#
|
546
|
+
# # Store the question for display
|
547
|
+
# @question = query_params[:question]
|
548
|
+
#
|
549
|
+
# respond_to do |format|
|
550
|
+
# format.turbo_stream
|
551
|
+
# format.html { render :new }
|
552
|
+
# end
|
553
|
+
# end
|
554
|
+
#
|
555
|
+
# def run
|
556
|
+
# @query = Blazer::Query.new(statement: query_params[:sql])
|
557
|
+
#
|
558
|
+
# # Use Blazer's run method to execute the query
|
559
|
+
# @result = Blazer.data_sources[@data_source].run_statement(@query.statement)
|
560
|
+
#
|
561
|
+
# # Check for errors
|
562
|
+
# if @result.error.present?
|
563
|
+
# @error = @result.error
|
564
|
+
# end
|
565
|
+
#
|
566
|
+
# respond_to do |format|
|
567
|
+
# format.turbo_stream
|
568
|
+
# format.html
|
569
|
+
# end
|
570
|
+
# end
|
571
|
+
#
|
572
|
+
# private
|
573
|
+
#
|
574
|
+
# def set_data_source
|
575
|
+
# @data_source = params[:data_source] || Blazer.data_sources.keys.first
|
576
|
+
# end
|
577
|
+
#
|
578
|
+
# def query_params
|
579
|
+
# params.require(:query).permit(:question, :sql, :data_source)
|
580
|
+
# end
|
581
|
+
#
|
582
|
+
# def generate_sql_from_question(question)
|
583
|
+
# # Placeholder for AI integration
|
584
|
+
# # In production, this would call your AI service (OpenAI, Anthropic, etc.)
|
585
|
+
# "-- AI generated SQL for: #{question}\n" +
|
586
|
+
# "-- TODO: Integrate with AI service\n" +
|
587
|
+
# "SELECT 'Please configure AI service' as message;"
|
588
|
+
# end
|
589
|
+
#
|
590
|
+
# def blazer_user
|
591
|
+
# # Override this method to provide the current user
|
592
|
+
# # For example: current_user.email if using Devise
|
593
|
+
# "sage-user"
|
594
|
+
# end
|
595
|
+
# end
|
596
|
+
# end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Sage
|
2
|
+
module ApplicationHelper
|
3
|
+
include Pagy::Frontend
|
4
|
+
|
5
|
+
# Provide access to Sage engine routes
|
6
|
+
def sage
|
7
|
+
Sage::Engine.routes.url_helpers
|
8
|
+
end
|
9
|
+
|
10
|
+
def messages_grouped_by_day(messages)
|
11
|
+
messages.group_by { |message| message.created_at.to_date }
|
12
|
+
.sort_by { |date, _| date }
|
13
|
+
end
|
14
|
+
|
15
|
+
def format_message_date(date)
|
16
|
+
case date
|
17
|
+
when Date.current
|
18
|
+
"Today"
|
19
|
+
when Date.current - 1
|
20
|
+
"Yesterday"
|
21
|
+
else
|
22
|
+
if date.year == Date.current.year
|
23
|
+
date.strftime("%B %d")
|
24
|
+
else
|
25
|
+
date.strftime("%B %d, %Y")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Sage
|
2
|
+
module QueriesHelper
|
3
|
+
def messages_grouped_by_day(messages)
|
4
|
+
messages.group_by { |message| message.created_at.to_date }
|
5
|
+
.sort_by { |date, _| date }
|
6
|
+
end
|
7
|
+
|
8
|
+
def format_message_date(date)
|
9
|
+
case date
|
10
|
+
when Date.current
|
11
|
+
"Today"
|
12
|
+
when Date.current - 1
|
13
|
+
"Yesterday"
|
14
|
+
else
|
15
|
+
if date.year == Date.current.year
|
16
|
+
date.strftime("%B %d")
|
17
|
+
else
|
18
|
+
date.strftime("%B %d, %Y")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static values = { text: String }
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
console.log("Clipboard controller connected");
|
8
|
+
}
|
9
|
+
|
10
|
+
copy(event) {
|
11
|
+
event.preventDefault()
|
12
|
+
|
13
|
+
navigator.clipboard.writeText(this.textValue).then(() => {
|
14
|
+
const originalText = event.target.textContent
|
15
|
+
event.target.textContent = "Copied!"
|
16
|
+
event.target.classList.add("btn-success")
|
17
|
+
|
18
|
+
setTimeout(() => {
|
19
|
+
event.target.textContent = originalText
|
20
|
+
event.target.classList.remove("btn-success")
|
21
|
+
}, 2000)
|
22
|
+
}).catch(err => {
|
23
|
+
console.error('Failed to copy text: ', err)
|
24
|
+
})
|
25
|
+
}
|
26
|
+
}
|