query_console 0.1.0 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f57530c0dcf15456265758600392d0eeff17ba08cfff9f879d0afa31a1ab7e3
4
- data.tar.gz: 12952c2bac1d22594a51e06d7c44e892b8e9955381091c22eb696ce08f7a8668
3
+ metadata.gz: 8c73054ef91ed2d89682d757f0ec545db4f43273b32df46b9a1f57b8f72d92a2
4
+ data.tar.gz: 753f12bc25e012af3233b96d0c7326f7df307219aea79740f232d9e7dda41def
5
5
  SHA512:
6
- metadata.gz: c2c2c54e0c044763180321438121c11b66a5cabd8ebc6a1afe53eea8f58b22675424647b3c5b2bde568aeac62e4de5bfa2dcc2f1f32606f5a1a50eeb3960519f
7
- data.tar.gz: 28d136f7b2c95ae7aeb551c07bf38bc8a861ff434885a75cbf805773935d4b3df8df57f2a165f9aca7e5ee23169822e635cd23b884d998626a1a7d654c012b57
6
+ metadata.gz: 0a0eff39813e9f070ef5df3b2965b0d6c39c4bb34c958efb2511bfec614c40fed59d76406b9b119b78a8f9e9e41914810aab439f2f9ea4555843f785ac787cdf
7
+ data.tar.gz: d3ef86f6fd078fa1844b38212acf27def791c8031886c9132f6b476b06db344687d37b1c97930cd514f487dd25b048263f04927c9f19c71e1e4c5013253f9840
data/README.md CHANGED
@@ -4,6 +4,7 @@ A Rails engine that provides a secure, mountable web interface for running read-
4
4
 
5
5
  ## Features
6
6
 
7
+ ### Core Features (v0.1.0)
7
8
  - 🔒 **Security First**: Read-only queries enforced at multiple levels
8
9
  - 🚦 **Environment Gating**: Disabled by default in production
9
10
  - 🔑 **Flexible Authorization**: Integrate with your existing auth system
@@ -14,6 +15,13 @@ A Rails engine that provides a secure, mountable web interface for running read-
14
15
  - ⚡ **Hotwire-Powered**: Uses Turbo Frames and Stimulus for smooth, SPA-like experience
15
16
  - 🎨 **Zero Build Step**: CDN-hosted Hotwire, no asset compilation needed
16
17
 
18
+ ### New in v0.2.0 🚀
19
+ - 📊 **EXPLAIN Query Plans**: Analyze query execution plans for performance debugging
20
+ - 🗂️ **Schema Explorer**: Browse tables, columns, types with quick actions
21
+ - 💾 **Saved Queries**: Save, organize, import/export your important queries (client-side)
22
+ - 🎨 **Tabbed UI**: Switch between History and Schema views seamlessly
23
+ - 🔍 **Quick Actions**: Generate queries from schema, copy names, insert WHERE clauses
24
+
17
25
  ## Security Features
18
26
 
19
27
  QueryConsole implements multiple layers of security:
@@ -70,6 +78,17 @@ QueryConsole.configure do |config|
70
78
  # Optional: Adjust limits
71
79
  # config.max_rows = 1000
72
80
  # config.timeout_ms = 5000
81
+
82
+ # v0.2.0+ Features
83
+ # EXPLAIN feature (default: enabled)
84
+ # config.enable_explain = true
85
+ # config.enable_explain_analyze = false # Disabled by default for safety
86
+
87
+ # Schema Explorer (default: enabled)
88
+ # config.schema_explorer = true
89
+ # config.schema_cache_seconds = 60
90
+ # config.schema_table_denylist = ["schema_migrations", "ar_internal_metadata"]
91
+ # config.schema_allowlist = [] # Optional: whitelist specific tables
73
92
  end
74
93
  ```
75
94
 
@@ -15,7 +15,8 @@ module QueryConsole
15
15
  config = QueryConsole.configuration
16
16
 
17
17
  unless config.enabled_environments.map(&:to_s).include?(Rails.env.to_s)
18
- raise ActionController::RoutingError, "Not Found"
18
+ render plain: "Not Found", status: :not_found
19
+ return false
19
20
  end
20
21
  end
21
22
 
@@ -25,13 +26,15 @@ module QueryConsole
25
26
  # Default deny if no authorize hook is configured
26
27
  if config.authorize.nil?
27
28
  Rails.logger.warn("[QueryConsole] Access denied: No authorization hook configured")
28
- raise ActionController::RoutingError, "Not Found"
29
+ render plain: "Not Found", status: :not_found
30
+ return false
29
31
  end
30
32
 
31
33
  # Call the authorization hook
32
34
  unless config.authorize.call(self)
33
35
  Rails.logger.warn("[QueryConsole] Access denied by authorization hook")
34
- raise ActionController::RoutingError, "Not Found"
36
+ render plain: "Not Found", status: :not_found
37
+ return false
35
38
  end
36
39
  end
37
40
  end
@@ -0,0 +1,47 @@
1
+ module QueryConsole
2
+ class ExplainController < ApplicationController
3
+ skip_forgery_protection only: [:create] # Allow Turbo Frame POST requests
4
+
5
+ def create
6
+ sql = params[:sql]
7
+
8
+ if sql.blank?
9
+ @result = ExplainRunner::ExplainResult.new(error: "Query cannot be empty")
10
+ respond_to do |format|
11
+ format.turbo_stream do
12
+ render turbo_stream: turbo_stream.replace(
13
+ "explain-results",
14
+ partial: "explain/results",
15
+ locals: { result: @result }
16
+ )
17
+ end
18
+ format.html { render "explain/_results", layout: false, locals: { result: @result } }
19
+ end
20
+ return
21
+ end
22
+
23
+ # Execute the EXPLAIN
24
+ runner = ExplainRunner.new(sql)
25
+ @result = runner.execute
26
+
27
+ # Log the EXPLAIN execution
28
+ AuditLogger.log_query(
29
+ sql: "EXPLAIN: #{sql}",
30
+ result: @result,
31
+ controller: self
32
+ )
33
+
34
+ # Respond with Turbo Stream or HTML
35
+ respond_to do |format|
36
+ format.turbo_stream do
37
+ render turbo_stream: turbo_stream.replace(
38
+ "explain-results",
39
+ partial: "query_console/explain/results",
40
+ locals: { result: @result }
41
+ )
42
+ end
43
+ format.html { render "query_console/explain/_results", layout: false, locals: { result: @result } }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,5 +1,7 @@
1
1
  module QueryConsole
2
2
  class QueriesController < ApplicationController
3
+ skip_forgery_protection only: [:run] # Allow Turbo Frame POST requests
4
+
3
5
  def new
4
6
  # Render the main query editor page
5
7
  end
@@ -0,0 +1,32 @@
1
+ module QueryConsole
2
+ class SchemaController < ApplicationController
3
+ def tables
4
+ unless QueryConsole.configuration.schema_explorer
5
+ render json: { error: "Schema explorer is disabled" }, status: :forbidden
6
+ return
7
+ end
8
+
9
+ introspector = SchemaIntrospector.new
10
+ @tables = introspector.tables
11
+
12
+ render json: @tables
13
+ end
14
+
15
+ def show
16
+ unless QueryConsole.configuration.schema_explorer
17
+ render json: { error: "Schema explorer is disabled" }, status: :forbidden
18
+ return
19
+ end
20
+
21
+ introspector = SchemaIntrospector.new
22
+ @table = introspector.table_details(params[:name])
23
+
24
+ if @table.nil?
25
+ render json: { error: "Table not found or access denied" }, status: :not_found
26
+ return
27
+ end
28
+
29
+ render json: @table
30
+ end
31
+ end
32
+ end
@@ -15,16 +15,20 @@ module QueryConsole
15
15
  actor: resolved_actor,
16
16
  sql: sql.to_s.strip,
17
17
  duration_ms: result.execution_time_ms,
18
- rows: result.row_count_shown,
19
18
  status: result.success? ? "ok" : "error"
20
19
  }
21
20
 
21
+ # Add row count if available (for QueryResult)
22
+ if result.respond_to?(:row_count_shown)
23
+ log_data[:rows] = result.row_count_shown
24
+ end
25
+
22
26
  if result.failure?
23
27
  log_data[:error] = result.error
24
28
  log_data[:error_class] = determine_error_class(result.error)
25
29
  end
26
30
 
27
- if result.truncated?
31
+ if result.respond_to?(:truncated) && result.truncated
28
32
  log_data[:truncated] = true
29
33
  log_data[:max_rows] = config.max_rows
30
34
  end
@@ -0,0 +1,137 @@
1
+ require 'timeout'
2
+
3
+ module QueryConsole
4
+ class ExplainRunner
5
+ class ExplainResult
6
+ attr_reader :plan_text, :execution_time_ms, :error
7
+
8
+ def initialize(plan_text: nil, execution_time_ms: 0, error: nil)
9
+ @plan_text = plan_text
10
+ @execution_time_ms = execution_time_ms
11
+ @error = error
12
+ end
13
+
14
+ def success?
15
+ @error.nil?
16
+ end
17
+
18
+ def failure?
19
+ !success?
20
+ end
21
+ end
22
+
23
+ def initialize(sql, config = QueryConsole.configuration)
24
+ @sql = sql
25
+ @config = config
26
+ end
27
+
28
+ def execute
29
+ return ExplainResult.new(error: "EXPLAIN feature is disabled") unless @config.enable_explain
30
+
31
+ start_time = Time.now
32
+
33
+ # Step 1: Validate SQL (same as regular runner)
34
+ validator = SqlValidator.new(@sql, @config)
35
+ validation_result = validator.validate
36
+
37
+ if validation_result.invalid?
38
+ return ExplainResult.new(error: validation_result.error)
39
+ end
40
+
41
+ sanitized_sql = validation_result.sanitized_sql
42
+
43
+ # Step 2: Build EXPLAIN query based on adapter
44
+ explain_sql = build_explain_query(sanitized_sql)
45
+
46
+ # Step 3: Execute with timeout
47
+ begin
48
+ result = execute_with_timeout(explain_sql)
49
+ execution_time = ((Time.now - start_time) * 1000).round(2)
50
+
51
+ # Format the result as plain text
52
+ plan_text = format_explain_output(result)
53
+
54
+ ExplainResult.new(
55
+ plan_text: plan_text,
56
+ execution_time_ms: execution_time
57
+ )
58
+ rescue Timeout::Error
59
+ ExplainResult.new(
60
+ error: "EXPLAIN timeout: exceeded #{@config.timeout_ms}ms limit"
61
+ )
62
+ rescue StandardError => e
63
+ ExplainResult.new(
64
+ error: "EXPLAIN error: #{e.message}"
65
+ )
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :sql, :config
72
+
73
+ def build_explain_query(sql)
74
+ adapter_name = ActiveRecord::Base.connection.adapter_name
75
+
76
+ case adapter_name
77
+ when "PostgreSQL"
78
+ if @config.enable_explain_analyze
79
+ "EXPLAIN (ANALYZE, FORMAT TEXT) #{sql}"
80
+ else
81
+ "EXPLAIN (FORMAT TEXT) #{sql}"
82
+ end
83
+ when "Mysql2", "Trilogy"
84
+ if @config.enable_explain_analyze
85
+ "EXPLAIN ANALYZE #{sql}"
86
+ else
87
+ "EXPLAIN #{sql}"
88
+ end
89
+ when "SQLite"
90
+ # SQLite doesn't support ANALYZE in EXPLAIN
91
+ "EXPLAIN QUERY PLAN #{sql}"
92
+ else
93
+ # Fallback for other adapters
94
+ "EXPLAIN #{sql}"
95
+ end
96
+ end
97
+
98
+ def execute_with_timeout(sql)
99
+ timeout_seconds = @config.timeout_ms / 1000.0
100
+
101
+ Timeout.timeout(timeout_seconds) do
102
+ ActiveRecord::Base.connection.exec_query(sql)
103
+ end
104
+ end
105
+
106
+ def format_explain_output(result)
107
+ # For SQLite, the result has columns like: id, parent, notused, detail
108
+ # For Postgres, it's usually a single "QUERY PLAN" column
109
+ # For MySQL, it varies by version
110
+
111
+ adapter_name = ActiveRecord::Base.connection.adapter_name
112
+
113
+ case adapter_name
114
+ when "SQLite"
115
+ # SQLite EXPLAIN QUERY PLAN format
116
+ lines = result.rows.map do |row|
117
+ # row is [id, parent, notused, detail]
118
+ detail = row[3] || row.last
119
+ detail.to_s
120
+ end
121
+ lines.join("\n")
122
+ when "PostgreSQL"
123
+ # Postgres returns single column with plan text
124
+ result.rows.map { |row| row[0].to_s }.join("\n")
125
+ when "Mysql2", "Trilogy"
126
+ # MySQL returns multiple columns, format as table
127
+ header = result.columns.join(" | ")
128
+ separator = "-" * header.length
129
+ rows = result.rows.map { |row| row.map(&:to_s).join(" | ") }
130
+ ([header, separator] + rows).join("\n")
131
+ else
132
+ # Generic fallback
133
+ result.rows.map { |row| row.join(" | ") }.join("\n")
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,244 @@
1
+ module QueryConsole
2
+ class SchemaIntrospector
3
+ def initialize(config = QueryConsole.configuration)
4
+ @config = config
5
+ end
6
+
7
+ def tables
8
+ return [] unless @config.schema_explorer
9
+
10
+ Rails.cache.fetch(cache_key_tables, expires_in: @config.schema_cache_seconds) do
11
+ fetch_tables
12
+ end
13
+ end
14
+
15
+ def table_details(table_name)
16
+ return nil unless @config.schema_explorer
17
+ return nil if table_denied?(table_name)
18
+
19
+ Rails.cache.fetch(cache_key_table(table_name), expires_in: @config.schema_cache_seconds) do
20
+ fetch_table_details(table_name)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :config
27
+
28
+ def fetch_tables
29
+ adapter_name = ActiveRecord::Base.connection.adapter_name
30
+
31
+ raw_tables = case adapter_name
32
+ when "PostgreSQL"
33
+ fetch_postgresql_tables
34
+ when "Mysql2", "Trilogy"
35
+ fetch_mysql_tables
36
+ when "SQLite"
37
+ fetch_sqlite_tables
38
+ else
39
+ # Fallback for other adapters
40
+ ActiveRecord::Base.connection.tables.map { |name| { name: name, kind: "table" } }
41
+ end
42
+
43
+ # Apply filtering
44
+ filter_tables(raw_tables)
45
+ end
46
+
47
+ def fetch_postgresql_tables
48
+ sql = <<~SQL
49
+ SELECT
50
+ table_name as name,
51
+ table_type as kind
52
+ FROM information_schema.tables
53
+ WHERE table_schema = 'public'
54
+ ORDER BY table_name
55
+ SQL
56
+
57
+ result = ActiveRecord::Base.connection.exec_query(sql)
58
+ result.rows.map do |row|
59
+ {
60
+ name: row[0],
61
+ kind: row[1].downcase.include?("view") ? "view" : "table"
62
+ }
63
+ end
64
+ end
65
+
66
+ def fetch_mysql_tables
67
+ sql = <<~SQL
68
+ SELECT
69
+ table_name as name,
70
+ table_type as kind
71
+ FROM information_schema.tables
72
+ WHERE table_schema = DATABASE()
73
+ ORDER BY table_name
74
+ SQL
75
+
76
+ result = ActiveRecord::Base.connection.exec_query(sql)
77
+ result.rows.map do |row|
78
+ {
79
+ name: row[0],
80
+ kind: row[1].downcase.include?("view") ? "view" : "table"
81
+ }
82
+ end
83
+ end
84
+
85
+ def fetch_sqlite_tables
86
+ sql = <<~SQL
87
+ SELECT name, type
88
+ FROM sqlite_master
89
+ WHERE type IN ('table', 'view')
90
+ AND name NOT LIKE 'sqlite_%'
91
+ ORDER BY name
92
+ SQL
93
+
94
+ result = ActiveRecord::Base.connection.exec_query(sql)
95
+ result.rows.map do |row|
96
+ {
97
+ name: row[0],
98
+ kind: row[1]
99
+ }
100
+ end
101
+ end
102
+
103
+ def fetch_table_details(table_name)
104
+ adapter_name = ActiveRecord::Base.connection.adapter_name
105
+
106
+ columns = case adapter_name
107
+ when "PostgreSQL"
108
+ fetch_postgresql_columns(table_name)
109
+ when "Mysql2", "Trilogy"
110
+ fetch_mysql_columns(table_name)
111
+ when "SQLite"
112
+ fetch_sqlite_columns(table_name)
113
+ else
114
+ # Fallback using ActiveRecord
115
+ fetch_activerecord_columns(table_name)
116
+ end
117
+
118
+ {
119
+ name: table_name,
120
+ columns: columns
121
+ }
122
+ end
123
+
124
+ def fetch_postgresql_columns(table_name)
125
+ sql = <<~SQL
126
+ SELECT
127
+ column_name,
128
+ data_type,
129
+ is_nullable,
130
+ column_default
131
+ FROM information_schema.columns
132
+ WHERE table_schema = 'public'
133
+ AND table_name = '#{sanitize_table_name(table_name)}'
134
+ ORDER BY ordinal_position
135
+ SQL
136
+
137
+ result = ActiveRecord::Base.connection.exec_query(sql)
138
+ result.rows.map do |row|
139
+ {
140
+ name: row[0],
141
+ db_type: row[1],
142
+ nullable: row[2] == "YES",
143
+ default: row[3]
144
+ }
145
+ end
146
+ end
147
+
148
+ def fetch_mysql_columns(table_name)
149
+ sql = <<~SQL
150
+ SELECT
151
+ column_name,
152
+ data_type,
153
+ is_nullable,
154
+ column_default
155
+ FROM information_schema.columns
156
+ WHERE table_schema = DATABASE()
157
+ AND table_name = '#{sanitize_table_name(table_name)}'
158
+ ORDER BY ordinal_position
159
+ SQL
160
+
161
+ result = ActiveRecord::Base.connection.exec_query(sql)
162
+ result.rows.map do |row|
163
+ {
164
+ name: row[0],
165
+ db_type: row[1],
166
+ nullable: row[2] == "YES",
167
+ default: row[3]
168
+ }
169
+ end
170
+ end
171
+
172
+ def fetch_sqlite_columns(table_name)
173
+ sql = "PRAGMA table_info(#{sanitize_table_name(table_name)})"
174
+ result = ActiveRecord::Base.connection.exec_query(sql)
175
+
176
+ result.rows.map do |row|
177
+ # SQLite PRAGMA returns: cid, name, type, notnull, dflt_value, pk
178
+ {
179
+ name: row[1],
180
+ db_type: row[2],
181
+ nullable: row[3] == 0,
182
+ default: row[4]
183
+ }
184
+ end
185
+ end
186
+
187
+ def fetch_activerecord_columns(table_name)
188
+ columns = ActiveRecord::Base.connection.columns(table_name)
189
+ columns.map do |col|
190
+ {
191
+ name: col.name,
192
+ db_type: col.sql_type,
193
+ nullable: col.null,
194
+ default: col.default
195
+ }
196
+ end
197
+ rescue StandardError
198
+ []
199
+ end
200
+
201
+ def filter_tables(tables)
202
+ tables.select do |table|
203
+ # Check denylist
204
+ next false if @config.schema_table_denylist.include?(table[:name])
205
+
206
+ # Check allowlist (if present)
207
+ if @config.schema_allowlist.any?
208
+ next @config.schema_allowlist.include?(table[:name])
209
+ end
210
+
211
+ true
212
+ end
213
+ end
214
+
215
+ def table_denied?(table_name)
216
+ # Check denylist
217
+ return true if @config.schema_table_denylist.include?(table_name)
218
+
219
+ # Check allowlist (if present)
220
+ if @config.schema_allowlist.any?
221
+ return !@config.schema_allowlist.include?(table_name)
222
+ end
223
+
224
+ false
225
+ end
226
+
227
+ def sanitize_table_name(name)
228
+ # Basic SQL injection protection - only allow alphanumeric, underscore
229
+ name.to_s.gsub(/[^a-zA-Z0-9_]/, '')
230
+ end
231
+
232
+ def cache_key_tables
233
+ "query_console/schema/tables/#{adapter_identifier}"
234
+ end
235
+
236
+ def cache_key_table(table_name)
237
+ "query_console/schema/table/#{adapter_identifier}/#{table_name}"
238
+ end
239
+
240
+ def adapter_identifier
241
+ ActiveRecord::Base.connection.adapter_name.downcase
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,89 @@
1
+ <style>
2
+ .explain-container {
3
+ margin-top: 20px;
4
+ }
5
+
6
+ .explain-header {
7
+ background: #f8f9fa;
8
+ padding: 12px 16px;
9
+ border-radius: 4px 4px 0 0;
10
+ border: 1px solid #dee2e6;
11
+ border-bottom: none;
12
+ display: flex;
13
+ justify-content: space-between;
14
+ align-items: center;
15
+ }
16
+
17
+ .explain-header h4 {
18
+ margin: 0;
19
+ font-size: 14px;
20
+ font-weight: 600;
21
+ color: #495057;
22
+ }
23
+
24
+ .explain-metadata {
25
+ font-size: 12px;
26
+ color: #6c757d;
27
+ }
28
+
29
+ .explain-content {
30
+ background: #fff;
31
+ border: 1px solid #dee2e6;
32
+ border-radius: 0 0 4px 4px;
33
+ padding: 16px;
34
+ max-height: 400px;
35
+ overflow: auto;
36
+ }
37
+
38
+ .explain-plan {
39
+ font-family: 'Courier New', Courier, monospace;
40
+ font-size: 13px;
41
+ line-height: 1.6;
42
+ white-space: pre-wrap;
43
+ word-wrap: break-word;
44
+ background: #f8f9fa;
45
+ padding: 12px;
46
+ border-radius: 4px;
47
+ color: #212529;
48
+ }
49
+
50
+ .explain-error {
51
+ background: #f8d7da;
52
+ color: #721c24;
53
+ padding: 12px 16px;
54
+ border-radius: 4px;
55
+ border: 1px solid #f5c6cb;
56
+ }
57
+
58
+ .explain-error-icon {
59
+ font-weight: bold;
60
+ margin-right: 8px;
61
+ }
62
+ </style>
63
+
64
+ <%= turbo_frame_tag "explain-results" do %>
65
+ <div class="explain-container">
66
+ <% if result.failure? %>
67
+ <div class="explain-error">
68
+ <span class="explain-error-icon">⚠</span>
69
+ <strong>EXPLAIN Error:</strong> <%= result.error %>
70
+ </div>
71
+ <% else %>
72
+ <div class="explain-header">
73
+ <h4>Query Execution Plan</h4>
74
+ <div class="explain-metadata">
75
+ Execution time: <strong><%= result.execution_time_ms %>ms</strong>
76
+ </div>
77
+ </div>
78
+ <div class="explain-content">
79
+ <% if result.plan_text.present? %>
80
+ <div class="explain-plan"><%= result.plan_text %></div>
81
+ <% else %>
82
+ <div style="color: #6c757d; text-align: center; padding: 20px;">
83
+ No execution plan available.
84
+ </div>
85
+ <% end %>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
@@ -2,6 +2,9 @@
2
2
  <style>
3
3
  .results-container {
4
4
  margin-top: 20px;
5
+ width: 100%;
6
+ min-width: 0;
7
+ overflow: hidden;
5
8
  }
6
9
 
7
10
  .error-message {
@@ -66,7 +69,8 @@
66
69
  }
67
70
 
68
71
  .results-table {
69
- width: 100%;
72
+ width: max-content;
73
+ min-width: 100%;
70
74
  border-collapse: collapse;
71
75
  font-size: 14px;
72
76
  background: white;