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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +202 -0
  3. data/app/assets/images/chevron-down-zinc-500.svg +1 -0
  4. data/app/assets/images/chevron-right.svg +1 -0
  5. data/app/assets/images/loading.svg +4 -0
  6. data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
  7. data/app/assets/images/sage/chevron-right.svg +1 -0
  8. data/app/assets/images/sage/loading.svg +4 -0
  9. data/app/assets/javascripts/sage/application.js +18 -0
  10. data/app/assets/stylesheets/sage/application.css +308 -0
  11. data/app/controllers/sage/actions_controller.rb +5 -0
  12. data/app/controllers/sage/application_controller.rb +4 -0
  13. data/app/controllers/sage/base_controller.rb +10 -0
  14. data/app/controllers/sage/checks_controller.rb +65 -0
  15. data/app/controllers/sage/dashboards_controller.rb +130 -0
  16. data/app/controllers/sage/queries/messages_controller.rb +62 -0
  17. data/app/controllers/sage/queries_controller.rb +596 -0
  18. data/app/helpers/sage/application_helper.rb +30 -0
  19. data/app/helpers/sage/queries_helper.rb +23 -0
  20. data/app/javascript/controllers/element_removal_controller.js +7 -0
  21. data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
  22. data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
  23. data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
  24. data/app/javascript/sage/controllers/search_controller.js +47 -0
  25. data/app/javascript/sage/controllers/select_controller.js +215 -0
  26. data/app/javascript/sage.js +19 -0
  27. data/app/jobs/sage/application_job.rb +4 -0
  28. data/app/jobs/sage/process_report_job.rb +80 -0
  29. data/app/mailers/sage/application_mailer.rb +6 -0
  30. data/app/models/sage/application_record.rb +5 -0
  31. data/app/models/sage/message.rb +8 -0
  32. data/app/schemas/sage/report_response_schema.rb +8 -0
  33. data/app/views/layouts/application.html.erb +34 -0
  34. data/app/views/layouts/sage/application.html.erb +94 -0
  35. data/app/views/sage/checks/_form.html.erb +81 -0
  36. data/app/views/sage/checks/_search.html.erb +8 -0
  37. data/app/views/sage/checks/edit.html.erb +10 -0
  38. data/app/views/sage/checks/index.html.erb +58 -0
  39. data/app/views/sage/checks/new.html.erb +8 -0
  40. data/app/views/sage/dashboards/_form.html.erb +50 -0
  41. data/app/views/sage/dashboards/_search.html.erb +8 -0
  42. data/app/views/sage/dashboards/index.html.erb +58 -0
  43. data/app/views/sage/dashboards/new.html.erb +8 -0
  44. data/app/views/sage/dashboards/show.html.erb +58 -0
  45. data/app/views/sage/messages/_form.html.erb +14 -0
  46. data/app/views/sage/queries/_caching.html.erb +17 -0
  47. data/app/views/sage/queries/_form.html.erb +72 -0
  48. data/app/views/sage/queries/_input.html.erb +17 -0
  49. data/app/views/sage/queries/_message.html.erb +25 -0
  50. data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
  51. data/app/views/sage/queries/_new_form.html.erb +43 -0
  52. data/app/views/sage/queries/_run.html.erb +232 -0
  53. data/app/views/sage/queries/_search.html.erb +8 -0
  54. data/app/views/sage/queries/_statement_box.html.erb +241 -0
  55. data/app/views/sage/queries/_streaming_message.html.erb +14 -0
  56. data/app/views/sage/queries/create.turbo_stream.erb +114 -0
  57. data/app/views/sage/queries/edit.html.erb +48 -0
  58. data/app/views/sage/queries/index.html.erb +59 -0
  59. data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
  60. data/app/views/sage/queries/messages/index.html.erb +44 -0
  61. data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
  62. data/app/views/sage/queries/new.html.erb +195 -0
  63. data/app/views/sage/queries/run.html.erb +1 -0
  64. data/app/views/sage/queries/run.turbo_stream.erb +3 -0
  65. data/app/views/sage/queries/show.html.erb +49 -0
  66. data/app/views/sage/queries/table_schema.html.erb +77 -0
  67. data/app/views/sage/shared/_navigation.html.erb +26 -0
  68. data/app/views/sage/shared/_overlay.html.erb +11 -0
  69. data/config/importmap.rb +11 -0
  70. data/config/initializers/pagy.rb +2 -0
  71. data/config/initializers/ransack.rb +152 -0
  72. data/config/routes.rb +31 -0
  73. data/lib/generators/sage/USAGE +13 -0
  74. data/lib/generators/sage/install/install_generator.rb +128 -0
  75. data/lib/generators/sage/install/templates/sage.rb +22 -0
  76. data/lib/sage/database_schema_context.rb +56 -0
  77. data/lib/sage/engine.rb +260 -0
  78. data/lib/sage/model_scopes_context.rb +185 -0
  79. data/lib/sage/report_processor.rb +263 -0
  80. data/lib/sage/version.rb +3 -0
  81. data/lib/sage.rb +25 -0
  82. data/lib/tasks/sage_tasks.rake +4 -0
  83. metadata +245 -0
@@ -0,0 +1,43 @@
1
+ <% if @query.errors.any? %>
2
+ <article class="border red left-round">
3
+ <i>error</i>
4
+ <div>
5
+ <h6>Error</h6>
6
+ <p><%= @query.errors.full_messages.first %></p>
7
+ </div>
8
+ </article>
9
+ <% end %>
10
+
11
+ <% @variable_params = @query.persisted? ? variable_params(@query) : nested_variable_params(@query) %>
12
+
13
+ <%= form_for @query, url: (@query.persisted? ? query_path(@query, params: @variable_params) : queries_path(params: @variable_params)), html: {autocomplete: "off", class: ""} do |f| %>
14
+ <div class="grid">
15
+ <div class="s10">
16
+ <div class="field label border">
17
+ <%= f.text_field :name %>
18
+ <%= f.label :name, class: (@query.name.present? ? "active" : "") %>
19
+ </div>
20
+
21
+ <div class="field textarea label border">
22
+ <%= f.text_area :description, style: "height: 80px; resize: vertical;" %>
23
+ <%= f.label :description, "Description (Optional)", class: (@query.description.present? ? "active" : "") %>
24
+ </div>
25
+
26
+ <%= f.hidden_field :statement, id: 'query_statement_form' %>
27
+ </div>
28
+
29
+ <div class="s2">
30
+ <nav style="display: flex; flex-direction: column; gap: 12px; padding-left: 8px;">
31
+ <% if @query.persisted? %>
32
+ <%= link_to query_path(@query), method: :delete, "data-confirm" => "Are you sure?", class: "button red", title: "Delete" do %>
33
+ <i>delete</i>
34
+ <% end %>
35
+ <% end %>
36
+ <button type="submit" name="commit" value="<%= @query.persisted? ? "Update" : "Create" %>" class="button primary" title="<%= @query.persisted? ? "Update" : "Create" %>">
37
+ <i><%= @query.persisted? ? "save" : "add" %></i>
38
+ </button>
39
+ </nav>
40
+ </div>
41
+ </div>
42
+ <% end %>
43
+
@@ -0,0 +1,232 @@
1
+ <%= turbo_frame_tag dom_id(@query, 'results') do %>
2
+ <% if @error %>
3
+ <div style="display: flex; justify-content: center; align-items: center; min-height: 400px; padding: 40px 20px;">
4
+ <div style="text-align: center; width: 100%; max-width: 90%;">
5
+ <div style="margin-bottom: 20px;">
6
+ <svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="opacity: 0.5;">
7
+ <circle cx="12" cy="12" r="10" stroke="#dc3545" stroke-width="2"/>
8
+ <path d="M12 8V12" stroke="#dc3545" stroke-width="2" stroke-linecap="round"/>
9
+ <circle cx="12" cy="16" r="1" fill="#dc3545"/>
10
+ </svg>
11
+ </div>
12
+ <h3 style="color: #dc3545; margin-bottom: 15px; font-weight: 500;">Query Error</h3>
13
+ <div style="background-color: #f8f9fa; border-left: 4px solid #dc3545; padding: 15px 20px; text-align: left; border-radius: 4px; margin-bottom: 20px; overflow-x: auto;">
14
+ <pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;"><code style="color: #495057; font-size: 14px; line-height: 1.5; font-family: 'Courier New', Courier, monospace;"><%= @error.first(1000) %></code></pre>
15
+ </div>
16
+ <p style="color: #6c757d; font-size: 14px; margin: 0;">
17
+ Please check your query syntax and try again.
18
+ </p>
19
+ </div>
20
+ </div>
21
+ <% elsif !@success %>
22
+ <% if @only_chart %>
23
+ <p class="text-muted">Select variables</p>
24
+ <% else %>
25
+ <div class="alert alert-info">Can't preview queries with variables</div>
26
+ <% end %>
27
+ <% elsif @cohort_analysis %>
28
+ <% if @cohort_error %>
29
+ <div class="alert alert-info"><%= @cohort_error %></div>
30
+ <% else %>
31
+ <%= render partial: "cohorts" %>
32
+ <% end %>
33
+ <% else %>
34
+ <% unless @only_chart %>
35
+ <%= render partial: "caching" %>
36
+ <p class="text-muted" style="margin-bottom: 10px;">
37
+ <% if @row_limit && @rows.size > @row_limit %>
38
+ First
39
+ <% @rows = @rows.first(@row_limit) %>
40
+ <% end %>
41
+ <%= pluralize(@rows.size, "row") %>
42
+
43
+ <% @checks.select(&:state).each do |check| %>
44
+ &middot; <small class="check-state <%= check.state.parameterize.gsub("-", "_") %>"><%= link_to check.state.upcase, edit_check_path(check) %></small>
45
+ <% if check.try(:message) %>
46
+ &middot; <%= check.message %>
47
+ <% end %>
48
+ <% end %>
49
+
50
+ <% if @query && @result.forecastable? && !params[:forecast] %>
51
+ &middot;
52
+ <%= link_to "Forecast", query_path(@query, params: {forecast: "t"}.merge(variable_params(@query))) %>
53
+ <% end %>
54
+ </p>
55
+ <% end %>
56
+ <% if @forecast_error %>
57
+ <div class="alert alert-danger"><%= @forecast_error %></div>
58
+ <% end %>
59
+ <% if @cohort_error %>
60
+ <div class="alert alert-info"><%= @cohort_error %></div>
61
+ <% end %>
62
+ <% if @rows.any? %>
63
+ <% values = @rows.first %>
64
+ <% chart_id = SecureRandom.hex %>
65
+ <% column_types = @result.column_types %>
66
+ <% chart_type = @result.chart_type %>
67
+ <% chart_options = {id: chart_id, thousands: t("number.format.delimiter"), decimal: t("number.format.separator")} %>
68
+ <% if ["line", "line2"].include?(chart_type) %>
69
+ <% chart_options.merge!(min: nil) %>
70
+ <% end %>
71
+ <% if chart_type == "scatter" %>
72
+ <% chart_options.merge!(library: {tooltips: {intersect: false}}) %>
73
+ <% elsif ["bar", "bar2"].include?(chart_type) %>
74
+ <% chart_options.merge!(library: {tooltips: {intersect: false, axis: 'x'}}) %>
75
+ <% elsif chart_type != "pie" %>
76
+ <% if column_types.size == 2 || @forecast %>
77
+ <% chart_options.merge!(library: {tooltips: {intersect: false, axis: 'x'}}) %>
78
+ <% else %>
79
+ <%# chartjs axis: 'x' has poor behavior with multiple series %>
80
+ <% chart_options.merge!(library: {tooltips: {intersect: false}}) %>
81
+ <% end %>
82
+ <% end %>
83
+ <% series_library = {} %>
84
+ <% target_index = @columns.index { |k| k.downcase == "target" } %>
85
+ <% if target_index %>
86
+ <% color = "#109618" %>
87
+ <% series_library[target_index - 1] = {pointStyle: "line", pointBorderWidth: 0, hitRadius: 5, borderColor: color, pointBackgroundColor: color, backgroundColor: color, pointHoverBackgroundColor: color} %>
88
+ <% end %>
89
+ <% if @forecast %>
90
+ <% color = "#54a3ee" %>
91
+ <% series_library[1] = {borderDash: [8], borderColor: color, pointBackgroundColor: color, backgroundColor: color, pointHoverBackgroundColor: color} %>
92
+ <% end %>
93
+ <% if @markers.any? %>
94
+ <% map_id = SecureRandom.hex %>
95
+ <%= content_tag :div, nil, id: map_id, style: "height: #{@only_chart ? 545 : 200}px;" %>
96
+ <%= javascript_tag nonce: true do %>
97
+ <%= blazer_js_var "mapboxAccessToken", Blazer.mapbox_access_token %>
98
+ <%= blazer_js_var "markers", @markers %>
99
+ <%= blazer_js_var "mapId", map_id %>
100
+ new Mapkick.Map(mapId, markers, {accessToken: mapboxAccessToken, tooltips: {hover: false, html: true}});
101
+ <% end %>
102
+ <% elsif @geojson.any? %>
103
+ <% map_id = SecureRandom.hex %>
104
+ <%= content_tag :div, nil, id: map_id, style: "height: #{@only_chart ? 545 : 200}px;" %>
105
+ <%= javascript_tag nonce: true do %>
106
+ <%= blazer_js_var "mapboxAccessToken", Blazer.mapbox_access_token %>
107
+ <%= blazer_js_var "geojson", @geojson %>
108
+ <%= blazer_js_var "mapId", map_id %>
109
+ new Mapkick.AreaMap(mapId, geojson, {accessToken: mapboxAccessToken, tooltips: {hover: false, html: true}});
110
+ <% end %>
111
+ <% elsif chart_type == "line" %>
112
+ <% 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]} } %>
113
+ <% chart_height = @only_chart ? "600px" : "300px" %>
114
+ <div style="height: <%= chart_height %>;">
115
+ <%= line_chart chart_data, **chart_options.merge(height: chart_height) %>
116
+ </div>
117
+ <% elsif chart_type == "line2" %>
118
+ <% chart_height = @only_chart ? "600px" : "300px" %>
119
+ <div style="height: <%= chart_height %>;">
120
+ <%= line_chart @rows.group_by { |r| v = r[1]; (@smart_values[@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.merge(height: chart_height) %>
121
+ </div>
122
+ <% elsif chart_type == "pie" %>
123
+ <% chart_height = @only_chart ? "600px" : "300px" %>
124
+ <div style="height: <%= chart_height %>;">
125
+ <%= pie_chart @rows.map { |r| [(@smart_values[@columns[0]] || {})[r[0].to_s] || r[0], r[1]] }, **chart_options.merge(height: chart_height) %>
126
+ </div>
127
+ <% elsif chart_type == "bar" %>
128
+ <% chart_height = @only_chart ? "600px" : "300px" %>
129
+ <div style="height: <%= chart_height %>;">
130
+ <%= column_chart (values.size - 1).times.map { |i| name = @columns[i + 1]; {name: blazer_series_name(name), data: @rows.first(20).map { |r| [(@smart_values[@columns[0]] || {})[r[0].to_s] || r[0], r[i + 1]] } } }, **chart_options.merge(height: chart_height) %>
131
+ </div>
132
+ <% elsif chart_type == "bar2" %>
133
+ <% first_20 = @rows.group_by { |r| r[0] }.values.first(20).flatten(1) %>
134
+ <% labels = first_20.map { |r| r[0] }.uniq %>
135
+ <% series = first_20.map { |r| r[1] }.uniq %>
136
+ <% labels.each do |l| %>
137
+ <% series.each do |s| %>
138
+ <% first_20 << [l, s, 0] unless first_20.find { |r| r[0] == l && r[1] == s } %>
139
+ <% end %>
140
+ <% end %>
141
+ <% chart_height = @only_chart ? "600px" : "300px" %>
142
+ <div style="height: <%= chart_height %>;">
143
+ <%= column_chart first_20.group_by { |r| v = r[1]; (@smart_values[@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]; [(@smart_values[@columns[0]] || {})[v3.to_s] || v3, v2[2]] }} }, **chart_options.merge(height: chart_height) %>
144
+ </div>
145
+ <% elsif chart_type == "scatter" %>
146
+ <% chart_height = @only_chart ? "600px" : "300px" %>
147
+ <div style="height: <%= chart_height %>;">
148
+ <%= scatter_chart @rows, xtitle: @columns[0], ytitle: @columns[1], **chart_options.merge(height: chart_height) %>
149
+ </div>
150
+ <% elsif @only_chart %>
151
+ <% if @rows.size == 1 && @rows.first.size == 1 %>
152
+ <% v = @rows.first.first %>
153
+ <% if v.is_a?(String) && v == "" %>
154
+ <div class="text-muted">empty string</div>
155
+ <% else %>
156
+ <p style="font-size: 160px;"><%= blazer_format_value(@columns.first, v) %></p>
157
+ <% end %>
158
+ <% else %>
159
+ <% @no_chart = true %>
160
+ <% end %>
161
+ <% end %>
162
+
163
+ <% unless @only_chart && !@no_chart %>
164
+ <% header_width = 100 / @columns.size.to_f %>
165
+ <% # Determine if we're rendering a chart (check all chart conditions) %>
166
+ <% has_chart = @rows.any? && (
167
+ @markers.any? ||
168
+ @geojson.any? ||
169
+ ["line", "line2", "pie", "bar", "bar2", "scatter"].include?(@result.chart_type)
170
+ ) %>
171
+ <% table_height = has_chart ? "300px" : "600px" %>
172
+ <% if params[:from_show] %>
173
+ <% table_height_style = "overflow: auto; max-width: 100%;" %>
174
+ <% else %>
175
+ <% table_height_style = "overflow: auto; height: #{table_height}; max-width: 100%;" %>
176
+ <% end %>
177
+ <div class="results-container" style="<%= table_height_style %>">
178
+ <% if @columns == ["QUERY PLAN"] %>
179
+ <pre><code><%= @rows.map { |r| r[0] }.join("\n") %></code></pre>
180
+ <% elsif @columns == ["PLAN"] && @data_source.adapter == "druid" %>
181
+ <pre><code><%= @rows[0][0] %></code></pre>
182
+ <% else %>
183
+ <table class="stripes" style="width: auto; min-width: 100%;">
184
+ <thead>
185
+ <tr>
186
+ <% @columns.each_with_index do |key, i| %>
187
+ <% type = @column_types[i] %>
188
+ <th style="width: <%= header_width %>%;" data-sort="<%= type %>">
189
+ <div style="min-width: <%= @min_width_types.include?(i) ? 180 : 60 %>px;">
190
+ <%= key %>
191
+ </div>
192
+ </th>
193
+ <% end %>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ <% @rows.each do |row| %>
198
+ <tr>
199
+ <% row.each_with_index do |v, i| %>
200
+ <% k = @columns[i] %>
201
+ <td>
202
+ <% if v.is_a?(Time) %>
203
+ <% v = blazer_time_value(@data_source, k, v) %>
204
+ <% end %>
205
+
206
+ <% unless v.nil? %>
207
+ <% if v.is_a?(String) && v == "" %>
208
+ <div class="text-muted">empty string</div>
209
+ <% elsif @linked_columns[k] %>
210
+ <%= link_to blazer_format_value(k, v), @linked_columns[k].gsub("{value}", u(v.to_s)), target: "_blank" %>
211
+ <% else %>
212
+ <%= blazer_format_value(k, v) %>
213
+ <% end %>
214
+ <% end %>
215
+
216
+ <% if (v2 = @smart_values.dig(k, v&.to_s)) %>
217
+ <div class="text-muted"><%= v2 %></div>
218
+ <% end %>
219
+ </td>
220
+ <% end %>
221
+ </tr>
222
+ <% end %>
223
+ </tbody>
224
+ </table>
225
+ <% end %>
226
+ </div>
227
+ <% end %>
228
+ <% elsif @only_chart %>
229
+ <p class="text-muted">No rows</p>
230
+ <% end %>
231
+ <% end %>
232
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <%= search_form_for @q, url: root_path, class: 'field border prefix round', data: { controller: 'sage--search', turbo_frame: "queries" } do |f| %>
2
+
3
+ <i class="front">search</i>
4
+ <%= f.search_field :name_or_description_or_statement_cont,
5
+ placeholder: "Search queries by name, description or SQL...",
6
+ class: 'text',
7
+ data: { action: 'input->sage--search#submit' } %>
8
+ <% end %>
@@ -0,0 +1,241 @@
1
+ <%= turbo_stream_from "statements" %>
2
+
3
+ <style>
4
+ @keyframes glow-border {
5
+ 0% {
6
+ outline: 2px solid rgba(66, 165, 245, 0.8);
7
+ outline-offset: 0px;
8
+ }
9
+ 50% {
10
+ outline: 2px solid rgba(66, 165, 245, 1);
11
+ outline-offset: 3px;
12
+ }
13
+ 100% {
14
+ outline: 2px solid rgba(66, 165, 245, 0.8);
15
+ outline-offset: 0px;
16
+ }
17
+ }
18
+
19
+ .glow-button {
20
+ animation: glow-border 1.5s ease-in-out infinite;
21
+ position: relative;
22
+ }
23
+
24
+ .glow-button:hover {
25
+ animation-play-state: paused;
26
+ outline: 2px solid rgba(66, 165, 245, 1);
27
+ outline-offset: 2px;
28
+ }
29
+ </style>
30
+
31
+ <div id=<%= dom_id(query, 'statement-box') %> class='' style="position: relative;">
32
+ <%
33
+ # Handle different contexts - job vs regular view rendering
34
+ form_url = if local_assigns[:form_url]
35
+ local_assigns[:form_url]
36
+ elsif defined?(sage) && sage.respond_to?(:run_queries_path)
37
+ sage.run_queries_path
38
+ elsif respond_to?(:run_queries_path)
39
+ run_queries_path
40
+ else
41
+ # Fallback to engine route
42
+ Sage::Engine.routes.url_helpers.run_queries_path
43
+ end
44
+ %>
45
+ <%= form_with url: form_url, method: :post do |f| %>
46
+ <%= f.hidden_field :statement, id: 'query_statement' %>
47
+ <%= f.hidden_field :query_id, value: query.id %>
48
+ <%= f.hidden_field :data_source, value: query.data_source || Blazer.data_sources.keys.first %>
49
+ <div id="editor-container" style="position: relative; overflow: hidden;">
50
+ <div id="editor" style="height: 200px;" data-initial-content="<%= html_escape(local_assigns[:statement] || query.statement) %>"><%= local_assigns[:statement] || query.statement %></div>
51
+ <div style="position: absolute; bottom: 10px; right: 15px; z-index: 1000; border-radius: 4px; padding: 5px; display: flex; gap: 8px;">
52
+ <button type='button' onclick="formatSQL()" class="secondary">Format</button>
53
+ <button type='submit' class="<%= 'glow-button' if local_assigns[:statement].present? %>">Run</button>
54
+ </div>
55
+ </div>
56
+ <% end %>
57
+ </div>
58
+
59
+ <%= javascript_tag nonce: true do %>
60
+ function formatSQL() {
61
+ if (!window.aceEditor) return;
62
+
63
+ const sql = window.aceEditor.getValue();
64
+ const formatted = formatSQLString(sql);
65
+ window.aceEditor.setValue(formatted, -1);
66
+ }
67
+
68
+ function formatSQLString(sql) {
69
+ // SQL keywords that should be uppercase and on new lines
70
+ const mainKeywords = ['SELECT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT', 'OFFSET',
71
+ 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE',
72
+ 'UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT', 'WITH'];
73
+
74
+ const joinKeywords = ['LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'FULL OUTER JOIN', 'CROSS JOIN', 'JOIN', 'ON'];
75
+
76
+ const otherKeywords = ['AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'IS', 'NULL',
77
+ 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AS', 'DISTINCT', 'ALL',
78
+ 'ASC', 'DESC', 'CAST', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
79
+ 'COALESCE', 'NULLIF', 'DATE_TRUNC', 'EXTRACT', 'SUBSTRING',
80
+ 'CONCAT', 'LENGTH', 'LOWER', 'UPPER', 'TRIM', 'REPLACE'];
81
+
82
+ // Preserve string literals and comments
83
+ const preservedStrings = [];
84
+ let preservedIndex = 0;
85
+
86
+ // Replace strings with placeholders
87
+ sql = sql.replace(/'([^']|'')*'/g, (match) => {
88
+ preservedStrings.push(match);
89
+ return `__PRESERVED_STRING_${preservedIndex++}__`;
90
+ });
91
+
92
+ // Replace comments with placeholders
93
+ sql = sql.replace(/--[^\n]*/g, (match) => {
94
+ preservedStrings.push(match);
95
+ return `__PRESERVED_STRING_${preservedIndex++}__`;
96
+ });
97
+
98
+ sql = sql.replace(/\/\*[\s\S]*?\*\//g, (match) => {
99
+ preservedStrings.push(match);
100
+ return `__PRESERVED_STRING_${preservedIndex++}__`;
101
+ });
102
+
103
+ // Remove extra whitespace and normalize spaces
104
+ sql = sql.replace(/\s+/g, ' ').trim();
105
+
106
+ // Format main keywords - add newline before them
107
+ mainKeywords.forEach(keyword => {
108
+ const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
109
+ sql = sql.replace(regex, '\n' + keyword);
110
+ });
111
+
112
+ // Format JOIN keywords - add newline before them
113
+ joinKeywords.forEach(keyword => {
114
+ const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
115
+ sql = sql.replace(regex, '\n' + keyword);
116
+ });
117
+
118
+ // Make all SQL keywords uppercase (but not the preserved strings)
119
+ [...mainKeywords, ...joinKeywords, ...otherKeywords].forEach(keyword => {
120
+ const regex = new RegExp('\\b' + keyword + '\\b', 'gi');
121
+ sql = sql.replace(regex, keyword);
122
+ });
123
+
124
+ // Format commas in SELECT list - add newline and indent after comma
125
+ sql = sql.replace(/SELECT\s+(.*?)(?=\nFROM|\nWHERE|\nGROUP|\nHAVING|\nORDER|\nLIMIT|$)/gs, (match, selectList) => {
126
+ // Only format if there are multiple items
127
+ if (selectList.includes(',')) {
128
+ selectList = selectList.replace(/,\s*/g, ',\n ');
129
+ return 'SELECT\n ' + selectList.trim();
130
+ }
131
+ return 'SELECT ' + selectList.trim();
132
+ });
133
+
134
+ // Format AND/OR in WHERE clause - put on new line with indent
135
+ sql = sql.replace(/\n(WHERE|HAVING)\s+(.*?)(?=\n(?:GROUP|ORDER|LIMIT|FROM|SELECT|$))/gs, (match, keyword, condition) => {
136
+ condition = condition.replace(/\s+(AND|OR)\s+/gi, '\n $1 ');
137
+ return '\n' + keyword + ' ' + condition;
138
+ });
139
+
140
+ // Add indentation for subqueries
141
+ sql = sql.replace(/\((\s*SELECT[\s\S]*?)\)/g, (match, subquery) => {
142
+ const indentedSubquery = subquery.split('\n').map(line => ' ' + line).join('\n');
143
+ return '(\n' + indentedSubquery + '\n)';
144
+ });
145
+
146
+ // Clean up multiple consecutive newlines
147
+ sql = sql.replace(/\n\s*\n/g, '\n');
148
+
149
+ // Restore preserved strings
150
+ preservedStrings.forEach((str, index) => {
151
+ sql = sql.replace(`__PRESERVED_STRING_${index}__`, str);
152
+ });
153
+
154
+ // Remove leading newline if present
155
+ sql = sql.replace(/^\n+/, '');
156
+
157
+ // Ensure consistent spacing around operators
158
+ sql = sql.replace(/\s*=\s*/g, ' = ');
159
+ sql = sql.replace(/\s*<>\s*/g, ' <> ');
160
+ sql = sql.replace(/\s*!=\s*/g, ' != ');
161
+ sql = sql.replace(/\s*<=\s*/g, ' <= ');
162
+ sql = sql.replace(/\s*>=\s*/g, ' >= ');
163
+ sql = sql.replace(/\s*<\s*/g, ' < ');
164
+ sql = sql.replace(/\s*>\s*/g, ' > ');
165
+
166
+ return sql;
167
+ }
168
+
169
+ function initializeEditor() {
170
+ // Destroy existing editor if it exists
171
+ if (window.aceEditor) {
172
+ window.aceEditor.destroy();
173
+ }
174
+
175
+ var editor = ace.edit("editor")
176
+ window.aceEditor = editor;
177
+ editor.setTheme("ace/theme/twilight")
178
+ editor.getSession().setMode("ace/mode/sql")
179
+ editor.setOptions({
180
+ enableBasicAutocompletion: false,
181
+ enableSnippets: false,
182
+ enableLiveAutocompletion: false,
183
+ highlightActiveLine: false,
184
+ fontSize: 12,
185
+ minLines: 10
186
+ })
187
+ editor.renderer.setShowGutter(true)
188
+ editor.renderer.setPrintMarginColumn(false)
189
+ editor.setShowPrintMargin(false)
190
+ editor.renderer.setPadding(10)
191
+ editor.getSession().setUseWrapMode(true)
192
+
193
+ // Ensure scrolling works properly with bottom margin
194
+ editor.renderer.setScrollMargin(0, 0, 50, 0) // top, right, bottom, left margins
195
+ editor.setAutoScrollEditorIntoView(true)
196
+
197
+ // Update both hidden fields when editor content changes
198
+ editor.getSession().on("change", function () {
199
+ const value = editor.getValue()
200
+ $("#query_statement").val(value) // For run form
201
+ $("#query_statement_form").val(value) // For main form
202
+ })
203
+
204
+ // Set initial value from data attribute or current content
205
+ const editorDiv = document.getElementById("editor")
206
+ const initialContent = editorDiv.getAttribute("data-initial-content") || editor.getValue()
207
+
208
+ if (initialContent && initialContent !== editor.getValue()) {
209
+ editor.setValue(initialContent, -1) // -1 moves cursor to start
210
+ }
211
+
212
+ const initialValue = editor.getValue()
213
+ $("#query_statement").val(initialValue)
214
+ $("#query_statement_form").val(initialValue)
215
+ editor.focus()
216
+
217
+ // Add top padding to ace_scroller and gutter to keep them aligned
218
+ const aceScroller = document.querySelector('.ace_scroller');
219
+ const aceGutter = document.querySelector('.ace_gutter');
220
+
221
+ if (aceScroller) {
222
+ aceScroller.style.paddingTop = '10px';
223
+ }
224
+ if (aceGutter) {
225
+ aceGutter.style.paddingTop = '10px';
226
+ }
227
+ }
228
+
229
+ // Initialize on page load
230
+ document.addEventListener('turbo:load', initializeEditor);
231
+
232
+ // Initialize when this partial is replaced via Turbo Stream
233
+ document.addEventListener('turbo:frame-load', initializeEditor);
234
+
235
+ // Also initialize immediately if the DOM is already loaded
236
+ if (document.readyState === 'loading') {
237
+ document.addEventListener('DOMContentLoaded', initializeEditor);
238
+ } else {
239
+ initializeEditor();
240
+ }
241
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <div id="<%= stream_target_id %>">
2
+ <%= simple_format(content) %>
3
+ </div>
4
+ <script>
5
+ setTimeout(() => {
6
+ const streamElement = document.getElementById('<%= stream_target_id %>');
7
+ if (streamElement) {
8
+ const messagesDiv = streamElement.closest('[id$="_messages"]');
9
+ if (messagesDiv) {
10
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
11
+ }
12
+ }
13
+ }, 10);
14
+ </script>
@@ -0,0 +1,114 @@
1
+ <%= turbo_stream.update "query_result" do %>
2
+ <div class="query-result">
3
+ <h3>Generated SQL Query</h3>
4
+
5
+ <div class="sql-container">
6
+ <pre class="sql-code"><%= @query.statement %></pre>
7
+
8
+ <div class="query-actions">
9
+ <%= form_with url: run_query_path(id: @query.id || "temp"), method: :post, local: false do |f| %>
10
+ <%= hidden_field_tag "query[sql]", @query.statement %>
11
+ <%= hidden_field_tag "query[question]", @question %>
12
+
13
+ <div class="button-group">
14
+ <%= f.submit "Run Query", class: "btn btn-success" %>
15
+ <%= link_to "Copy SQL", "#",
16
+ class: "btn btn-secondary",
17
+ data: {
18
+ controller: "clipboard",
19
+ clipboard_text_value: @query.statement,
20
+ action: "click->clipboard#copy"
21
+ } %>
22
+ <% if @query.persisted? %>
23
+ <%= link_to "View in Blazer", main_app.blazer_query_path(@query), class: "btn btn-secondary", target: "_blank" %>
24
+ <% end %>
25
+ <%= link_to "New Query", new_query_path, class: "btn btn-outline", data: { turbo_frame: "_top" } %>
26
+ </div>
27
+ <% end %>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="query-metadata">
32
+ <p class="text-muted">
33
+ <strong>Original question:</strong> <%= @question %>
34
+ </p>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+
39
+ <style>
40
+ .query-result {
41
+ margin-top: 30px;
42
+ padding: 20px;
43
+ background-color: #f8f9fa;
44
+ border-radius: 8px;
45
+ }
46
+
47
+ .query-result h3 {
48
+ margin-bottom: 15px;
49
+ color: #333;
50
+ }
51
+
52
+ .sql-container {
53
+ background-color: white;
54
+ border: 1px solid #e0e0e0;
55
+ border-radius: 4px;
56
+ overflow: hidden;
57
+ }
58
+
59
+ .sql-code {
60
+ margin: 0;
61
+ padding: 15px;
62
+ background-color: #f5f5f5;
63
+ font-family: 'Monaco', 'Consolas', monospace;
64
+ font-size: 13px;
65
+ line-height: 1.5;
66
+ overflow-x: auto;
67
+ }
68
+
69
+ .query-actions {
70
+ padding: 15px;
71
+ background-color: white;
72
+ border-top: 1px solid #e0e0e0;
73
+ }
74
+
75
+ .button-group {
76
+ display: flex;
77
+ gap: 10px;
78
+ flex-wrap: wrap;
79
+ }
80
+
81
+ .btn-success {
82
+ background-color: #28a745;
83
+ color: white;
84
+ }
85
+
86
+ .btn-success:hover {
87
+ background-color: #218838;
88
+ }
89
+
90
+ .btn-secondary {
91
+ background-color: #6c757d;
92
+ color: white;
93
+ }
94
+
95
+ .btn-secondary:hover {
96
+ background-color: #5a6268;
97
+ }
98
+
99
+ .btn-outline {
100
+ background-color: white;
101
+ color: #333;
102
+ border: 1px solid #ddd;
103
+ }
104
+
105
+ .btn-outline:hover {
106
+ background-color: #f8f9fa;
107
+ }
108
+
109
+ .query-metadata {
110
+ margin-top: 15px;
111
+ padding-top: 15px;
112
+ border-top: 1px solid #e0e0e0;
113
+ }
114
+ </style>