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 +4 -4
- data/README.md +19 -0
- data/app/controllers/query_console/application_controller.rb +6 -3
- data/app/controllers/query_console/explain_controller.rb +47 -0
- data/app/controllers/query_console/queries_controller.rb +2 -0
- data/app/controllers/query_console/schema_controller.rb +32 -0
- data/app/services/query_console/audit_logger.rb +6 -2
- data/app/services/query_console/explain_runner.rb +137 -0
- data/app/services/query_console/schema_introspector.rb +244 -0
- data/app/views/query_console/explain/_results.html.erb +89 -0
- data/app/views/query_console/queries/_results.html.erb +5 -1
- data/app/views/query_console/queries/new.html.erb +720 -328
- data/config/importmap.rb +8 -0
- data/config/routes.rb +5 -0
- data/lib/query_console/configuration.rb +19 -1
- data/lib/query_console/version.rb +1 -1
- metadata +10 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c73054ef91ed2d89682d757f0ec545db4f43273b32df46b9a1f57b8f72d92a2
|
|
4
|
+
data.tar.gz: 753f12bc25e012af3233b96d0c7326f7df307219aea79740f232d9e7dda41def
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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:
|
|
72
|
+
width: max-content;
|
|
73
|
+
min-width: 100%;
|
|
70
74
|
border-collapse: collapse;
|
|
71
75
|
font-size: 14px;
|
|
72
76
|
background: white;
|