query_console 0.1.0 → 0.2.1

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.
@@ -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
@@ -20,6 +20,11 @@ module QueryConsole
20
20
  end
21
21
 
22
22
  def apply_limit
23
+ # Skip limiting for DML queries (INSERT, UPDATE, DELETE, MERGE)
24
+ if is_dml_query?
25
+ return LimitResult.new(sql: @sql, truncated: false)
26
+ end
27
+
23
28
  # Check if query already has a LIMIT clause
24
29
  if sql_has_limit?
25
30
  LimitResult.new(sql: @sql, truncated: false)
@@ -33,6 +38,11 @@ module QueryConsole
33
38
 
34
39
  attr_reader :sql, :max_rows, :config
35
40
 
41
+ def is_dml_query?
42
+ # Check if query is a DML operation (INSERT, UPDATE, DELETE, MERGE)
43
+ @sql.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
44
+ end
45
+
36
46
  def sql_has_limit?
37
47
  # Check for LIMIT clause (case-insensitive)
38
48
  # Match: "LIMIT", " LIMIT ", etc.
@@ -1,12 +1,13 @@
1
1
  module QueryConsole
2
2
  class SqlValidator
3
3
  class ValidationResult
4
- attr_reader :valid, :error, :sanitized_sql
4
+ attr_reader :valid, :error, :sanitized_sql, :is_dml
5
5
 
6
- def initialize(valid:, sanitized_sql: nil, error: nil)
6
+ def initialize(valid:, sanitized_sql: nil, error: nil, is_dml: false)
7
7
  @valid = valid
8
8
  @sanitized_sql = sanitized_sql
9
9
  @error = error
10
+ @is_dml = is_dml
10
11
  end
11
12
 
12
13
  def valid?
@@ -16,6 +17,10 @@ module QueryConsole
16
17
  def invalid?
17
18
  !@valid
18
19
  end
20
+
21
+ def dml?
22
+ @is_dml
23
+ end
19
24
  end
20
25
 
21
26
  def initialize(sql, config = QueryConsole.configuration)
@@ -41,16 +46,35 @@ module QueryConsole
41
46
 
42
47
  # Check if query starts with allowed keywords
43
48
  normalized_start = sanitized.downcase
44
- unless @config.allowed_starts_with.any? { |keyword| normalized_start.start_with?(keyword) }
49
+
50
+ # Define DML-specific keywords that are conditionally allowed
51
+ dml_keywords = %w[insert update delete merge]
52
+
53
+ # Expand allowed_starts_with if DML is enabled
54
+ effective_allowed = if @config.enable_dml
55
+ @config.allowed_starts_with + dml_keywords
56
+ else
57
+ @config.allowed_starts_with
58
+ end
59
+
60
+ unless effective_allowed.any? { |keyword| normalized_start.start_with?(keyword) }
45
61
  return ValidationResult.new(
46
62
  valid: false,
47
- error: "Query must start with one of: #{@config.allowed_starts_with.join(', ').upcase}"
63
+ error: "Query must start with one of: #{effective_allowed.join(', ').upcase}"
48
64
  )
49
65
  end
50
66
 
51
67
  # Check for forbidden keywords
52
68
  normalized_query = sanitized.downcase
53
- forbidden = @config.forbidden_keywords.find do |keyword|
69
+
70
+ # Filter forbidden keywords based on DML enablement
71
+ effective_forbidden = if @config.enable_dml
72
+ @config.forbidden_keywords.reject { |kw| dml_keywords.include?(kw) || kw == 'replace' || kw == 'into' }
73
+ else
74
+ @config.forbidden_keywords
75
+ end
76
+
77
+ forbidden = effective_forbidden.find do |keyword|
54
78
  # Match whole words to avoid false positives (e.g., "updates" table name)
55
79
  normalized_query.match?(/\b#{Regexp.escape(keyword.downcase)}\b/)
56
80
  end
@@ -62,7 +86,10 @@ module QueryConsole
62
86
  )
63
87
  end
64
88
 
65
- ValidationResult.new(valid: true, sanitized_sql: sanitized)
89
+ # Detect if this is a DML query
90
+ is_dml_query = sanitized.downcase.match?(/\A(insert|update|delete|merge)\b/)
91
+
92
+ ValidationResult.new(valid: true, sanitized_sql: sanitized, is_dml: is_dml_query)
66
93
  end
67
94
 
68
95
  private
@@ -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;
@@ -112,10 +116,33 @@
112
116
  color: #999;
113
117
  font-style: italic;
114
118
  }
119
+
120
+ .dml-warning {
121
+ background-color: #fff3cd;
122
+ border-left: 4px solid #ffc107;
123
+ padding: 12px 16px;
124
+ margin-bottom: 16px;
125
+ border-radius: 4px;
126
+ }
127
+
128
+ .dml-warning-icon {
129
+ color: #ff6b6b;
130
+ font-weight: bold;
131
+ margin-right: 8px;
132
+ }
115
133
  </style>
116
134
 
117
135
  <div class="results-container">
118
136
  <% result_to_render = local_assigns[:result] || @result %>
137
+ <% is_dml_result = local_assigns[:is_dml] || @is_dml %>
138
+
139
+ <% if is_dml_result %>
140
+ <div class="dml-warning">
141
+ <span class="dml-warning-icon">ℹ️</span>
142
+ <strong>Data Modified:</strong>
143
+ This query has modified the database. All changes are logged.
144
+ </div>
145
+ <% end %>
119
146
 
120
147
  <% if result_to_render.error %>
121
148
  <div class="error-message">
@@ -132,8 +159,13 @@
132
159
  <span class="metadata-value"><%= result_to_render.execution_time_ms %>ms</span>
133
160
  </div>
134
161
  <div class="metadata-item">
135
- <span class="metadata-label">Rows:</span>
136
- <span class="metadata-value"><%= result_to_render.row_count_shown %></span>
162
+ <% if is_dml_result && result_to_render.rows_affected %>
163
+ <span class="metadata-label">Rows Affected:</span>
164
+ <span class="metadata-value"><%= result_to_render.rows_affected %></span>
165
+ <% else %>
166
+ <span class="metadata-label">Rows:</span>
167
+ <span class="metadata-value"><%= result_to_render.row_count_shown %></span>
168
+ <% end %>
137
169
  </div>
138
170
  <% if result_to_render.truncated %>
139
171
  <div class="metadata-item">
@@ -144,7 +176,11 @@
144
176
 
145
177
  <% if result_to_render.rows.empty? %>
146
178
  <div class="empty-results">
147
- No rows returned
179
+ <% if is_dml_result && result_to_render.rows_affected %>
180
+ <%= result_to_render.rows_affected %> row(s) affected
181
+ <% else %>
182
+ No rows returned
183
+ <% end %>
148
184
  </div>
149
185
  <% else %>
150
186
  <div class="table-wrapper">