sql_genius 0.9.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 +7 -0
- data/CHANGELOG.md +195 -0
- data/LICENSE.txt +65 -0
- data/README.md +178 -0
- data/Rakefile +8 -0
- data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
- data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
- data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
- data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
- data/app/controllers/sql_genius/base_controller.rb +29 -0
- data/app/controllers/sql_genius/queries_controller.rb +94 -0
- data/app/views/layouts/sql_genius/application.html.erb +285 -0
- data/config/routes.rb +34 -0
- data/docs/guides/ai-features.md +115 -0
- data/docs/guides/getting-started-rails.md +118 -0
- data/docs/guides/ssh-tunnel-connections.md +151 -0
- data/docs/screenshots/ai_tools.png +0 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/duplicate_indexes.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/screenshots/query_stats.png +0 -0
- data/docs/screenshots/server.png +0 -0
- data/docs/screenshots/table_sizes.png +0 -0
- data/lib/generators/sql_genius/install/install_generator.rb +19 -0
- data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
- data/lib/sql_genius/configuration.rb +114 -0
- data/lib/sql_genius/core/ai/client.rb +155 -0
- data/lib/sql_genius/core/ai/config.rb +47 -0
- data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
- data/lib/sql_genius/core/ai/describe_query.rb +41 -0
- data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
- data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
- data/lib/sql_genius/core/ai/index_planner.rb +91 -0
- data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
- data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
- data/lib/sql_genius/core/ai/optimization.rb +81 -0
- data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
- data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
- data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
- data/lib/sql_genius/core/ai/schema_review.rb +46 -0
- data/lib/sql_genius/core/ai/suggestion.rb +74 -0
- data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
- data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
- data/lib/sql_genius/core/analysis/columns.rb +63 -0
- data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
- data/lib/sql_genius/core/analysis/query_history.rb +50 -0
- data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
- data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
- data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
- data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
- data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
- data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
- data/lib/sql_genius/core/column_definition.rb +30 -0
- data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
- data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
- data/lib/sql_genius/core/connection.rb +37 -0
- data/lib/sql_genius/core/execution_result.rb +27 -0
- data/lib/sql_genius/core/index_definition.rb +23 -0
- data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
- data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
- data/lib/sql_genius/core/query_builders.rb +27 -0
- data/lib/sql_genius/core/query_explainer.rb +113 -0
- data/lib/sql_genius/core/query_runner/config.rb +21 -0
- data/lib/sql_genius/core/query_runner.rb +123 -0
- data/lib/sql_genius/core/result.rb +43 -0
- data/lib/sql_genius/core/server_info.rb +54 -0
- data/lib/sql_genius/core/sql_validator.rb +149 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
- data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
- data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
- data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
- data/lib/sql_genius/core.rb +72 -0
- data/lib/sql_genius/engine.rb +31 -0
- data/lib/sql_genius/slow_query_monitor.rb +43 -0
- data/lib/sql_genius/version.rb +5 -0
- data/lib/sql_genius.rb +29 -0
- data/sql_genius.gemspec +47 -0
- metadata +171 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Runs EXPLAIN against a SELECT query via a Core::Connection. Optionally
|
|
6
|
+
# skips SQL validation (used for explaining captured slow queries from
|
|
7
|
+
# mysql's own logs where the exact text may include references to
|
|
8
|
+
# otherwise-blocked tables).
|
|
9
|
+
#
|
|
10
|
+
# Rejects obviously-truncated SQL — captured slow queries from the
|
|
11
|
+
# slow query log are capped at ~2000 characters, so if the last
|
|
12
|
+
# character doesn't look like a valid terminator we refuse to try.
|
|
13
|
+
# This avoids confusing EXPLAIN errors from partial statements.
|
|
14
|
+
#
|
|
15
|
+
# Reuses Core::QueryRunner::Rejected for validation failures so
|
|
16
|
+
# callers can rescue one error type for both runners.
|
|
17
|
+
class QueryExplainer
|
|
18
|
+
class Truncated < Core::Error; end
|
|
19
|
+
|
|
20
|
+
def initialize(connection, config)
|
|
21
|
+
@connection = connection
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def explain(sql, skip_validation: false)
|
|
26
|
+
unless skip_validation
|
|
27
|
+
error = SqlValidator.validate(
|
|
28
|
+
sql,
|
|
29
|
+
blocked_tables: @config.blocked_tables,
|
|
30
|
+
connection: @connection,
|
|
31
|
+
)
|
|
32
|
+
raise QueryRunner::Rejected, error if error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
clean_sql = normalize_placeholders(SqlValidator.normalize_identifier_quotes(sql, @connection).gsub(/;\s*\z/, ""))
|
|
36
|
+
|
|
37
|
+
unless looks_complete?(clean_sql)
|
|
38
|
+
raise Truncated, "This query appears to be truncated and cannot be explained."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@connection.exec_query("EXPLAIN #{clean_sql}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Captured digest text from pg_stat_statements has literals replaced with
|
|
47
|
+
# $1, $2, ... bind placeholders, and MySQL's performance_schema digest
|
|
48
|
+
# uses ?. Running EXPLAIN directly on that text fails ("there is no
|
|
49
|
+
# parameter $1" on PG, similar on MySQL). Substituting NULL lets the
|
|
50
|
+
# planner produce a plan — selectivity won't match the real query but
|
|
51
|
+
# the shape (join order, indexes used, scan types) is still useful.
|
|
52
|
+
#
|
|
53
|
+
# On PostgreSQL we only substitute $N — those don't appear in
|
|
54
|
+
# hand-written SQL and the substitution is unambiguous. On MySQL we
|
|
55
|
+
# only substitute ? outside of single-quoted strings to avoid mangling
|
|
56
|
+
# user-typed literals like `WHERE name = '?'`.
|
|
57
|
+
def normalize_placeholders(sql)
|
|
58
|
+
if @connection.server_version.postgresql?
|
|
59
|
+
sql.gsub(/\$\d+/, "NULL")
|
|
60
|
+
else
|
|
61
|
+
replace_unquoted_question_marks(sql)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Walks the SQL once, tracking whether we're inside a single-quoted
|
|
66
|
+
# string (with '' as the escape). Replaces ? with NULL only when
|
|
67
|
+
# outside a string literal.
|
|
68
|
+
def replace_unquoted_question_marks(sql)
|
|
69
|
+
out = +""
|
|
70
|
+
in_string = false
|
|
71
|
+
i = 0
|
|
72
|
+
while i < sql.length
|
|
73
|
+
ch = sql[i]
|
|
74
|
+
# Doubled single quote inside a string literal is an escaped quote;
|
|
75
|
+
# consume both chars without toggling in_string.
|
|
76
|
+
if in_string && ch == "'" && sql[i + 1] == "'"
|
|
77
|
+
out << "''"
|
|
78
|
+
i += 2
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
if ch == "'"
|
|
82
|
+
in_string = !in_string
|
|
83
|
+
out << ch
|
|
84
|
+
elsif !in_string && ch == "?"
|
|
85
|
+
out << "NULL"
|
|
86
|
+
else
|
|
87
|
+
out << ch
|
|
88
|
+
end
|
|
89
|
+
i += 1
|
|
90
|
+
end
|
|
91
|
+
out
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Heuristic: SQL ends with a value-like token (identifier, number, closing
|
|
95
|
+
# paren/bracket, or closing quote). A trailing SQL keyword such as WHERE,
|
|
96
|
+
# AND, OR, ON, JOIN, SET, HAVING, or a comma/operator means the statement
|
|
97
|
+
# was cut before its next token.
|
|
98
|
+
TRAILING_KEYWORD_PATTERN = /\b(WHERE|AND|OR|ON|JOIN|INNER|OUTER|LEFT|RIGHT|CROSS|HAVING|SET|BETWEEN|LIKE|IN|NOT|IS|FROM|SELECT|GROUP|ORDER|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT)\s*$/i
|
|
99
|
+
|
|
100
|
+
def looks_complete?(sql)
|
|
101
|
+
# Strip Rails-style query annotation comments (/*action=...*/) before
|
|
102
|
+
# inspecting the trailing token. Otherwise the `/` at the end of `*/`
|
|
103
|
+
# would trip the trailing-operator check and false-flag the statement
|
|
104
|
+
# as truncated.
|
|
105
|
+
bare = sql.gsub(%r{/\*.*?\*/}m, " ").strip
|
|
106
|
+
return false if bare.match?(TRAILING_KEYWORD_PATTERN)
|
|
107
|
+
return false if bare.match?(%r{[,=<>!(+\-*/]\s*$})
|
|
108
|
+
|
|
109
|
+
bare.match?(/[\w'"`)\]]\s*$/)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Forward declaration so Config can be namespaced under QueryRunner.
|
|
6
|
+
# The full QueryRunner class is defined in query_runner.rb.
|
|
7
|
+
class QueryRunner
|
|
8
|
+
Config = Struct.new(
|
|
9
|
+
:blocked_tables,
|
|
10
|
+
:masked_column_patterns,
|
|
11
|
+
:query_timeout_ms,
|
|
12
|
+
keyword_init: true,
|
|
13
|
+
) do
|
|
14
|
+
def initialize(*)
|
|
15
|
+
super
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module SqlGenius
|
|
6
|
+
module Core
|
|
7
|
+
# Runs SELECT queries against a Core::Connection with SQL validation,
|
|
8
|
+
# row-limit application, dialect-appropriate timeout hints, and column
|
|
9
|
+
# masking. Returns a Core::ExecutionResult on success or raises a
|
|
10
|
+
# specific error class on failure.
|
|
11
|
+
#
|
|
12
|
+
# Timeout strategy by vendor:
|
|
13
|
+
# MySQL — wraps SELECT with /*+ MAX_EXECUTION_TIME(ms) */ hint
|
|
14
|
+
# MariaDB — prefixes SQL with SET STATEMENT max_statement_time=s FOR
|
|
15
|
+
# PostgreSQL — issues SET statement_timeout = ms before the query and
|
|
16
|
+
# resets it to 0 in an ensure block (the server enforces
|
|
17
|
+
# the cancel-on-timeout behaviour)
|
|
18
|
+
#
|
|
19
|
+
# Does NOT handle audit logging — the caller is responsible for
|
|
20
|
+
# recording successful queries and errors using whatever logger it owns.
|
|
21
|
+
class QueryRunner
|
|
22
|
+
class Rejected < Core::Error; end
|
|
23
|
+
class Timeout < Core::Error; end
|
|
24
|
+
|
|
25
|
+
TIMEOUT_PATTERNS = [
|
|
26
|
+
"max_statement_time",
|
|
27
|
+
"max_execution_time",
|
|
28
|
+
"Query execution was interrupted",
|
|
29
|
+
"canceling statement due to statement timeout",
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def initialize(connection, config)
|
|
33
|
+
@connection = connection
|
|
34
|
+
@config = config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run(sql, row_limit:)
|
|
38
|
+
validation_error = SqlValidator.validate(
|
|
39
|
+
sql,
|
|
40
|
+
blocked_tables: @config.blocked_tables,
|
|
41
|
+
connection: @connection,
|
|
42
|
+
)
|
|
43
|
+
raise Rejected, validation_error if validation_error
|
|
44
|
+
|
|
45
|
+
normalized = SqlValidator.normalize_identifier_quotes(sql, @connection)
|
|
46
|
+
limited = SqlValidator.apply_row_limit(normalized, row_limit)
|
|
47
|
+
|
|
48
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
result = with_timeout do
|
|
50
|
+
@connection.exec_query(apply_timeout_hint(limited))
|
|
51
|
+
end
|
|
52
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
53
|
+
|
|
54
|
+
masked_rows = mask_rows(result)
|
|
55
|
+
|
|
56
|
+
ExecutionResult.new(
|
|
57
|
+
columns: result.columns,
|
|
58
|
+
rows: masked_rows,
|
|
59
|
+
execution_time_ms: duration_ms,
|
|
60
|
+
truncated: masked_rows.length >= row_limit,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def apply_timeout_hint(sql)
|
|
67
|
+
case vendor
|
|
68
|
+
when :mariadb
|
|
69
|
+
timeout_seconds = @config.query_timeout_ms / 1000
|
|
70
|
+
"SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
|
|
71
|
+
when :postgresql
|
|
72
|
+
# PostgreSQL timeout is set out-of-band in with_timeout; the
|
|
73
|
+
# query itself is sent unchanged.
|
|
74
|
+
sql
|
|
75
|
+
else
|
|
76
|
+
sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{@config.query_timeout_ms}) */")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def with_timeout
|
|
81
|
+
if vendor == :postgresql
|
|
82
|
+
@connection.exec_query("SET statement_timeout = #{@config.query_timeout_ms}")
|
|
83
|
+
begin
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
begin
|
|
87
|
+
@connection.exec_query("SET statement_timeout = 0")
|
|
88
|
+
rescue StandardError
|
|
89
|
+
# If the session is already torn down we can't restore — that's fine.
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
yield
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
raise Timeout, e.message if timeout_error?(e)
|
|
97
|
+
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def vendor
|
|
102
|
+
@connection.server_version.vendor
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mask_rows(result)
|
|
106
|
+
mask_indices = result.columns.each_with_index.select do |name, _i|
|
|
107
|
+
SqlValidator.masked_column?(name, @config.masked_column_patterns)
|
|
108
|
+
end.map { |(_name, i)| i }.to_set
|
|
109
|
+
|
|
110
|
+
return result.rows if mask_indices.empty?
|
|
111
|
+
|
|
112
|
+
result.rows.map do |row|
|
|
113
|
+
row.each_with_index.map { |value, i| mask_indices.include?(i) ? "[REDACTED]" : value }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def timeout_error?(exception)
|
|
118
|
+
msg = exception.message
|
|
119
|
+
TIMEOUT_PATTERNS.any? { |pattern| msg.include?(pattern) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable value object representing the result of a query.
|
|
6
|
+
# Adapters translate their native result types into this shape.
|
|
7
|
+
class Result
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
attr_reader :columns, :rows
|
|
11
|
+
|
|
12
|
+
def initialize(columns:, rows:)
|
|
13
|
+
@columns = columns.freeze
|
|
14
|
+
@rows = rows.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&block)
|
|
19
|
+
return @rows.each unless block
|
|
20
|
+
|
|
21
|
+
@rows.each(&block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_a
|
|
25
|
+
@rows.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def count
|
|
29
|
+
@rows.length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def empty?
|
|
33
|
+
@rows.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns rows as an array of hashes keyed by column name. Mirrors
|
|
37
|
+
# ActiveRecord::Result#to_a's hashification behavior.
|
|
38
|
+
def to_hashes
|
|
39
|
+
@rows.map { |row| @columns.zip(row).to_h }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Identifies the database vendor and version. Adapters construct one
|
|
6
|
+
# from the server's VERSION() output.
|
|
7
|
+
#
|
|
8
|
+
# Three vendors are recognised: :mysql, :mariadb, :postgresql.
|
|
9
|
+
# #dialect collapses these into the SQL family used by query builders
|
|
10
|
+
# — :mysql (covering both MySQL and MariaDB) or :postgresql.
|
|
11
|
+
class ServerInfo
|
|
12
|
+
attr_reader :vendor, :version
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def parse(version_string)
|
|
16
|
+
str = version_string.to_s
|
|
17
|
+
vendor = if str.match?(/postgresql/i)
|
|
18
|
+
:postgresql
|
|
19
|
+
elsif str.downcase.include?("mariadb")
|
|
20
|
+
:mariadb
|
|
21
|
+
else
|
|
22
|
+
:mysql
|
|
23
|
+
end
|
|
24
|
+
new(vendor: vendor, version: str)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# vendor must be :mysql, :mariadb, or :postgresql
|
|
29
|
+
def initialize(vendor:, version:)
|
|
30
|
+
@vendor = vendor
|
|
31
|
+
@version = version
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mariadb?
|
|
36
|
+
@vendor == :mariadb
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mysql?
|
|
40
|
+
@vendor == :mysql
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def postgresql?
|
|
44
|
+
@vendor == :postgresql
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# SQL family used to pick a query builder. MySQL and MariaDB share
|
|
48
|
+
# one dialect; PostgreSQL is its own.
|
|
49
|
+
def dialect
|
|
50
|
+
postgresql? ? :postgresql : :mysql
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module SqlValidator
|
|
6
|
+
FORBIDDEN_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "GRANT", "REVOKE"].freeze
|
|
7
|
+
|
|
8
|
+
MYSQL_SYSTEM_SCHEMAS = ["information_schema", "mysql", "performance_schema", "sys"].freeze
|
|
9
|
+
POSTGRESQL_SYSTEM_SCHEMAS = ["information_schema", "pg_catalog", "pg_toast", "pg_temp"].freeze
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
def validate(sql, blocked_tables:, connection:)
|
|
14
|
+
return "Please enter a query." if sql.nil? || sql.strip.empty?
|
|
15
|
+
|
|
16
|
+
normalized = sql.gsub(/--.*$/, "").gsub(%r{/\*.*?\*/}m, "").strip
|
|
17
|
+
|
|
18
|
+
unless normalized.match?(/\ASELECT\b/i) || normalized.match?(/\AWITH\b/i)
|
|
19
|
+
return "Only SELECT queries are allowed."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
system_schemas = system_schemas_for(connection)
|
|
23
|
+
if normalized.match?(/\b(#{system_schemas.join("|")})\b/i)
|
|
24
|
+
return "Access to system schemas is not allowed."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
|
28
|
+
return "#{keyword} statements are not allowed." if normalized.match?(/\b#{keyword}\b/i)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
tables_in_query = extract_table_references(normalized, connection)
|
|
32
|
+
blocked = tables_in_query & blocked_tables
|
|
33
|
+
if blocked.any?
|
|
34
|
+
return "Access denied for table(s): #{blocked.join(", ")}."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_table_references(sql, connection)
|
|
41
|
+
tables = []
|
|
42
|
+
sql.scan(/\bFROM\s+((?:["`]?\w+["`]?(?:\s*,\s*["`]?\w+["`]?)*)+)/i) do |m|
|
|
43
|
+
m[0].scan(/["`]?(\w+)["`]?/) { |t| tables << t[0] }
|
|
44
|
+
end
|
|
45
|
+
sql.scan(/\bJOIN\s+["`]?(\w+)["`]?/i) { |m| tables << m[0] }
|
|
46
|
+
sql.scan(/\b(?:INTO|UPDATE)\s+["`]?(\w+)["`]?/i) { |m| tables << m[0] }
|
|
47
|
+
tables.uniq.map(&:downcase) & connection.tables
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply_row_limit(sql, limit)
|
|
51
|
+
if sql.match?(/\bLIMIT\s+\d+\s*,\s*\d+/i)
|
|
52
|
+
sql.gsub(/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i) do
|
|
53
|
+
"LIMIT #{::Regexp.last_match(1).to_i}, #{[::Regexp.last_match(2).to_i, limit].min}"
|
|
54
|
+
end
|
|
55
|
+
elsif sql.match?(/\bLIMIT\s+\d+/i)
|
|
56
|
+
sql.gsub(/\bLIMIT\s+(\d+)/i) { "LIMIT #{[::Regexp.last_match(1).to_i, limit].min}" }
|
|
57
|
+
else
|
|
58
|
+
"#{sql.gsub(/;\s*\z/, "")} LIMIT #{limit}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_identifier_quotes(sql, connection)
|
|
63
|
+
quote = connection.quote_table_name("sql_genius_identifier_probe")[0]
|
|
64
|
+
return sql if quote == "`" || !sql.include?("`")
|
|
65
|
+
|
|
66
|
+
rewrite_backtick_identifiers(sql, connection)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def masked_column?(column_name, patterns)
|
|
70
|
+
patterns.any? { |pattern| column_name.downcase.include?(pattern) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def system_schemas_for(connection)
|
|
74
|
+
return MYSQL_SYSTEM_SCHEMAS unless connection.respond_to?(:server_version)
|
|
75
|
+
|
|
76
|
+
connection.server_version.postgresql? ? POSTGRESQL_SYSTEM_SCHEMAS : MYSQL_SYSTEM_SCHEMAS
|
|
77
|
+
rescue StandardError
|
|
78
|
+
MYSQL_SYSTEM_SCHEMAS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rewrite_backtick_identifiers(sql, connection)
|
|
82
|
+
output = +""
|
|
83
|
+
i = 0
|
|
84
|
+
|
|
85
|
+
while i < sql.length
|
|
86
|
+
char = sql[i]
|
|
87
|
+
|
|
88
|
+
if char == "'"
|
|
89
|
+
literal, i = read_single_quoted_literal(sql, i)
|
|
90
|
+
output << literal
|
|
91
|
+
elsif char == "`"
|
|
92
|
+
identifier, i = read_backtick_identifier(sql, i)
|
|
93
|
+
output << connection.quote_table_name(identifier)
|
|
94
|
+
else
|
|
95
|
+
output << char
|
|
96
|
+
i += 1
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
output
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_single_quoted_literal(sql, index)
|
|
104
|
+
output = +"'"
|
|
105
|
+
i = index + 1
|
|
106
|
+
|
|
107
|
+
while i < sql.length
|
|
108
|
+
output << sql[i]
|
|
109
|
+
if sql[i] == "'"
|
|
110
|
+
if sql[i + 1] == "'"
|
|
111
|
+
output << sql[i + 1]
|
|
112
|
+
i += 2
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
i += 1
|
|
117
|
+
break
|
|
118
|
+
end
|
|
119
|
+
i += 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
[output, i]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_backtick_identifier(sql, index)
|
|
126
|
+
output = +""
|
|
127
|
+
i = index + 1
|
|
128
|
+
|
|
129
|
+
while i < sql.length
|
|
130
|
+
if sql[i] == "`"
|
|
131
|
+
if sql[i + 1] == "`"
|
|
132
|
+
output << "`"
|
|
133
|
+
i += 2
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
i += 1
|
|
138
|
+
break
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
output << sql[i]
|
|
142
|
+
i += 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
[output, i]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!-- Explain Results -->
|
|
2
|
+
<div id="explain-results" class="mg-mt mg-hidden">
|
|
3
|
+
<div class="mg-card">
|
|
4
|
+
<div class="mg-card-header">
|
|
5
|
+
<span><strong>🔎 EXPLAIN Output</strong></span>
|
|
6
|
+
<div>
|
|
7
|
+
<% if @ai_enabled %>
|
|
8
|
+
<button id="explain-optimize" class="mg-btn mg-btn-outline mg-btn-sm">⚡ AI Optimization</button>
|
|
9
|
+
<button id="explain-index-advisor" class="mg-btn mg-btn-outline mg-btn-sm">⚡ Index Advisor</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
<button id="explain-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">✕ Close</button>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="mg-card-body">
|
|
15
|
+
<div class="mg-table-wrap">
|
|
16
|
+
<table class="mg-table">
|
|
17
|
+
<thead id="explain-thead"></thead>
|
|
18
|
+
<tbody id="explain-tbody"></tbody>
|
|
19
|
+
</table>
|
|
20
|
+
</div>
|
|
21
|
+
<div id="optimize-results" class="mg-hidden mg-mt">
|
|
22
|
+
<div id="optimize-content" class="mg-alert mg-alert-info"></div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<!-- Results Area -->
|
|
29
|
+
<div id="query-results" class="mg-mt mg-hidden">
|
|
30
|
+
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
31
|
+
<div id="results-stats" class="mg-mb mg-hidden">
|
|
32
|
+
<span id="results-row-count" class="mg-badge mg-badge-info"></span>
|
|
33
|
+
<span id="results-time" class="mg-badge mg-badge-secondary"></span>
|
|
34
|
+
<span id="results-truncated" class="mg-badge mg-badge-warning mg-hidden">Results truncated</span>
|
|
35
|
+
</div>
|
|
36
|
+
<button id="results-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm" style="margin-bottom:12px;">✕ Dismiss</button>
|
|
37
|
+
</div>
|
|
38
|
+
<div id="results-alert" class="mg-hidden"></div>
|
|
39
|
+
<div id="results-table-wrapper" class="mg-table-wrap mg-hidden">
|
|
40
|
+
<table class="mg-table">
|
|
41
|
+
<thead id="results-thead"></thead>
|
|
42
|
+
<tbody id="results-tbody"></tbody>
|
|
43
|
+
</table>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="results-empty" class="mg-text-center mg-text-muted mg-hidden">No rows returned.</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- AI Query Analysis Results -->
|
|
49
|
+
<div id="ai-query-result" class="mg-mt mg-hidden">
|
|
50
|
+
<div class="mg-card">
|
|
51
|
+
<div class="mg-card-header">
|
|
52
|
+
<span id="ai-query-title"><strong>⚡ AI Analysis</strong></span>
|
|
53
|
+
<button id="ai-query-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">✕</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="mg-card-body">
|
|
56
|
+
<div id="ai-query-content" style="font-size:13px;"></div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!-- AI Tools Tab -->
|
|
2
|
+
<div class="mg-tab-content" id="tab-aitools">
|
|
3
|
+
<!-- Schema Review -->
|
|
4
|
+
<div class="mg-card mg-mb">
|
|
5
|
+
<div class="mg-card-header"><strong>⚡ Schema Review</strong></div>
|
|
6
|
+
<div class="mg-card-body">
|
|
7
|
+
<div class="mg-text-muted mg-mb" style="font-size:12px;">Analyze your schema for anti-patterns: inappropriate column types, missing indexes, naming inconsistencies, and more.</div>
|
|
8
|
+
<div class="mg-row" style="align-items:flex-end;">
|
|
9
|
+
<div class="mg-col-4 mg-field">
|
|
10
|
+
<label for="schema-table">Table (leave blank for all)</label>
|
|
11
|
+
<select id="schema-table">
|
|
12
|
+
<option value="">All tables (top 20)</option>
|
|
13
|
+
<% @all_tables.each do |table| %>
|
|
14
|
+
<option value="<%= table %>"><%= table %></option>
|
|
15
|
+
<% end %>
|
|
16
|
+
</select>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="mg-field">
|
|
19
|
+
<button id="schema-review-btn" class="mg-btn mg-btn-primary mg-btn-sm">⚡ Analyze Schema</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
<div id="schema-result" class="mg-mt mg-hidden">
|
|
23
|
+
<div id="schema-result-content" style="font-size:13px;"></div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<!-- Migration Risk Assessment -->
|
|
29
|
+
<div class="mg-card">
|
|
30
|
+
<div class="mg-card-header"><strong>⚡ Migration Risk Assessment</strong></div>
|
|
31
|
+
<div class="mg-card-body">
|
|
32
|
+
<div class="mg-text-muted mg-mb" style="font-size:12px;">Paste a Rails migration or DDL and get a risk assessment: lock duration, impact on active queries, deployment strategy.</div>
|
|
33
|
+
<div class="mg-field">
|
|
34
|
+
<textarea id="migration-input" rows="8" placeholder="class AddIndexToUsers < ActiveRecord::Migration[7.0] def change add_index :users, :email, unique: true end end"></textarea>
|
|
35
|
+
</div>
|
|
36
|
+
<button id="migration-assess-btn" class="mg-btn mg-btn-primary mg-btn-sm">⚡ Assess Risk</button>
|
|
37
|
+
<div id="migration-result" class="mg-mt mg-hidden">
|
|
38
|
+
<div id="migration-risk-badge" style="margin-bottom:8px;"></div>
|
|
39
|
+
<div id="migration-result-content" style="font-size:13px;"></div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|