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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Reviews a schema for anti-patterns. Takes a specific table name
|
|
7
|
+
# (or nil to review the top 20 queryable tables).
|
|
8
|
+
class SchemaReview
|
|
9
|
+
def initialize(client, config, connection)
|
|
10
|
+
@client = client
|
|
11
|
+
@config = config
|
|
12
|
+
@connection = connection
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(table)
|
|
16
|
+
tables_to_review = table.nil? || table.to_s.empty? ? @connection.tables.first(20) : [table]
|
|
17
|
+
schema_desc = SchemaContextBuilder.new(@connection).call(tables_to_review, detail: :basic)
|
|
18
|
+
|
|
19
|
+
messages = [
|
|
20
|
+
{ role: "system", content: system_prompt },
|
|
21
|
+
{ role: "user", content: schema_desc },
|
|
22
|
+
]
|
|
23
|
+
@client.chat(messages: messages)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def system_prompt
|
|
29
|
+
<<~PROMPT
|
|
30
|
+
You are a #{DialectHints.name_for(@connection)} schema reviewer. Analyze the following schema and identify anti-patterns and improvement opportunities. Look for:
|
|
31
|
+
- Inappropriate column types (VARCHAR(255) for short values, TEXT where VARCHAR suffices, INT for booleans)
|
|
32
|
+
- Missing indexes on foreign key columns or frequently filtered columns
|
|
33
|
+
- Missing NOT NULL constraints where NULLs are unlikely
|
|
34
|
+
- ENUM columns that should be lookup tables
|
|
35
|
+
- Missing created_at/updated_at timestamps
|
|
36
|
+
- Tables without a PRIMARY KEY
|
|
37
|
+
- Overly wide indexes or redundant indexes
|
|
38
|
+
- Column naming inconsistencies
|
|
39
|
+
#{@config.domain_context}
|
|
40
|
+
Respond with JSON: {"findings": "markdown-formatted findings organized by severity (Critical, Warning, Suggestion). Include specific ALTER TABLE statements where applicable."}
|
|
41
|
+
PROMPT
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Turns a natural-language prompt + a list of allowed tables into
|
|
7
|
+
# a SELECT query via the AI client.
|
|
8
|
+
#
|
|
9
|
+
# Construct with:
|
|
10
|
+
# connection - a Core::Connection implementation
|
|
11
|
+
# client - a Core::Ai::Client (pre-built with the same config)
|
|
12
|
+
# config - the Core::Ai::Config (used for system_context)
|
|
13
|
+
#
|
|
14
|
+
# Call:
|
|
15
|
+
# .call(user_prompt, allowed_tables) -> Hash with "sql" and "explanation"
|
|
16
|
+
class Suggestion
|
|
17
|
+
def initialize(connection, client, config)
|
|
18
|
+
@connection = connection
|
|
19
|
+
@client = client
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(user_prompt, allowed_tables)
|
|
24
|
+
schema = build_schema_description(allowed_tables)
|
|
25
|
+
messages = [
|
|
26
|
+
{ role: "system", content: system_prompt(schema) },
|
|
27
|
+
{ role: "user", content: user_prompt },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
@client.chat(messages: messages)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def system_prompt(schema_description)
|
|
36
|
+
prompt = +"You are a SQL query assistant for a #{DialectHints.name_for(@connection)} database.\n"
|
|
37
|
+
|
|
38
|
+
if @config.system_context && !@config.system_context.empty?
|
|
39
|
+
prompt += <<~PROMPT
|
|
40
|
+
|
|
41
|
+
Domain context:
|
|
42
|
+
#{@config.system_context}
|
|
43
|
+
PROMPT
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
prompt += <<~PROMPT
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
- Only generate SELECT statements. Never generate INSERT, UPDATE, DELETE, or any other mutation.
|
|
50
|
+
- Only reference the tables and columns listed in the schema below. Do not guess or invent column names.
|
|
51
|
+
- #{DialectHints.identifier_quoting_rule(@connection)}
|
|
52
|
+
- Include a LIMIT 100 unless the user specifies otherwise.
|
|
53
|
+
|
|
54
|
+
Available schema:
|
|
55
|
+
#{schema_description}
|
|
56
|
+
|
|
57
|
+
Respond with JSON: {"sql": "the SQL query", "explanation": "brief explanation of what the query does"}
|
|
58
|
+
PROMPT
|
|
59
|
+
|
|
60
|
+
prompt
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_schema_description(allowed_tables)
|
|
64
|
+
allowed_tables.map do |table|
|
|
65
|
+
next unless @connection.tables.include?(table)
|
|
66
|
+
|
|
67
|
+
columns = @connection.columns_for(table).map { |c| "#{c.name} (#{c.type})" }
|
|
68
|
+
"#{table}: #{columns.join(", ")}"
|
|
69
|
+
end.compact.join("\n")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Reviews MySQL configuration variables against best practices for
|
|
7
|
+
# the observed workload. Gathers SHOW GLOBAL VARIABLES (filtered to
|
|
8
|
+
# ~20 performance-relevant keys) and SHOW GLOBAL STATUS, then asks
|
|
9
|
+
# the LLM to identify misconfigurations.
|
|
10
|
+
class VariableReviewer
|
|
11
|
+
RELEVANT_VARIABLES = [
|
|
12
|
+
"innodb_buffer_pool_size",
|
|
13
|
+
"innodb_log_file_size",
|
|
14
|
+
"innodb_flush_log_at_trx_commit",
|
|
15
|
+
"max_connections",
|
|
16
|
+
"query_cache_type",
|
|
17
|
+
"sort_buffer_size",
|
|
18
|
+
"join_buffer_size",
|
|
19
|
+
"tmp_table_size",
|
|
20
|
+
"max_heap_table_size",
|
|
21
|
+
"thread_cache_size",
|
|
22
|
+
"table_open_cache",
|
|
23
|
+
"innodb_file_per_table",
|
|
24
|
+
"innodb_flush_method",
|
|
25
|
+
"binlog_format",
|
|
26
|
+
"sync_binlog",
|
|
27
|
+
"innodb_io_capacity",
|
|
28
|
+
"innodb_read_io_threads",
|
|
29
|
+
"innodb_write_io_threads",
|
|
30
|
+
"long_query_time",
|
|
31
|
+
"slow_query_log",
|
|
32
|
+
"performance_schema",
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
RELEVANT_STATUS_KEYS = [
|
|
36
|
+
"Innodb_buffer_pool_reads",
|
|
37
|
+
"Innodb_buffer_pool_read_requests",
|
|
38
|
+
"Created_tmp_disk_tables",
|
|
39
|
+
"Created_tmp_tables",
|
|
40
|
+
"Sort_merge_passes",
|
|
41
|
+
"Threads_created",
|
|
42
|
+
"Threads_connected",
|
|
43
|
+
"Max_used_connections",
|
|
44
|
+
"Slow_queries",
|
|
45
|
+
"Questions",
|
|
46
|
+
"Uptime",
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
def initialize(client, config, connection)
|
|
50
|
+
@client = client
|
|
51
|
+
@config = config
|
|
52
|
+
@connection = connection
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call
|
|
56
|
+
if @connection.server_version.postgresql?
|
|
57
|
+
raise Core::UnsupportedDialect.for_postgresql("Variable Config Reviewer")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
variables = fetch_variables
|
|
61
|
+
status = fetch_status
|
|
62
|
+
|
|
63
|
+
messages = [
|
|
64
|
+
{ role: "system", content: system_prompt },
|
|
65
|
+
{ role: "user", content: user_prompt(variables, status) },
|
|
66
|
+
]
|
|
67
|
+
@client.chat(messages: messages)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def fetch_variables
|
|
73
|
+
result = @connection.exec_query("SHOW GLOBAL VARIABLES")
|
|
74
|
+
result.rows
|
|
75
|
+
.select { |row| RELEVANT_VARIABLES.include?(row[0]) }
|
|
76
|
+
.map { |row| [row[0], row[1]] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch_status
|
|
80
|
+
result = @connection.exec_query("SHOW GLOBAL STATUS")
|
|
81
|
+
result.rows
|
|
82
|
+
.select { |row| RELEVANT_STATUS_KEYS.include?(row[0]) }
|
|
83
|
+
.map { |row| [row[0], row[1]] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def system_prompt
|
|
87
|
+
<<~PROMPT
|
|
88
|
+
You are a MySQL configuration reviewer. Analyze the server variables and status counters below, then identify misconfigurations and improvement opportunities. Consider:
|
|
89
|
+
- Buffer pool sizing relative to workload (hit rate from status counters)
|
|
90
|
+
- Temporary table spills to disk (tmp_table_size vs Created_tmp_disk_tables)
|
|
91
|
+
- Sort buffer and join buffer sizing
|
|
92
|
+
- Connection pool sizing (max_connections vs Max_used_connections)
|
|
93
|
+
- Thread cache effectiveness
|
|
94
|
+
- InnoDB flush and sync settings for durability vs performance trade-offs
|
|
95
|
+
- Slow query log configuration
|
|
96
|
+
- Binary log format suitability
|
|
97
|
+
#{@config.domain_context}
|
|
98
|
+
Respond with JSON: {"findings": "markdown-formatted analysis organized by severity (Critical, Warning, Suggestion). Include specific SET GLOBAL or my.cnf recommendations with before/after values."}
|
|
99
|
+
PROMPT
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def user_prompt(variables, status)
|
|
103
|
+
lines = ["== Server Variables =="]
|
|
104
|
+
variables.each { |name, value| lines << "#{name} = #{value}" }
|
|
105
|
+
lines << ""
|
|
106
|
+
lines << "== Server Status Counters =="
|
|
107
|
+
status.each { |name, value| lines << "#{name} = #{value}" }
|
|
108
|
+
lines.join("\n")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Ai
|
|
6
|
+
# Produces a high-level executive summary of the query workload by
|
|
7
|
+
# pulling the top statements from performance_schema and asking the
|
|
8
|
+
# LLM to characterize read/write ratio, access patterns, waste
|
|
9
|
+
# concentration, and highest-leverage optimization opportunities.
|
|
10
|
+
#
|
|
11
|
+
# Construct with:
|
|
12
|
+
# connection - a Core::Connection implementation
|
|
13
|
+
# client - a Core::Ai::Client
|
|
14
|
+
# config - the Core::Ai::Config
|
|
15
|
+
#
|
|
16
|
+
# Call:
|
|
17
|
+
# .call() -> Hash with "digest" key containing markdown analysis
|
|
18
|
+
class WorkloadDigest
|
|
19
|
+
TOP_N = 30
|
|
20
|
+
|
|
21
|
+
def initialize(connection, client, config)
|
|
22
|
+
@connection = connection
|
|
23
|
+
@client = client
|
|
24
|
+
@config = config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
stats = Analysis::QueryStats.new(@connection).call(sort: "total_time", limit: TOP_N)
|
|
29
|
+
formatted = format_stats(stats)
|
|
30
|
+
|
|
31
|
+
messages = [
|
|
32
|
+
{ role: "system", content: system_prompt },
|
|
33
|
+
{ role: "user", content: user_prompt(formatted, stats.length) },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@client.chat(messages: messages)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def system_prompt
|
|
42
|
+
prompt = <<~PROMPT
|
|
43
|
+
You are a #{DialectHints.name_for(@connection)} performance analyst producing an executive workload digest.
|
|
44
|
+
PROMPT
|
|
45
|
+
|
|
46
|
+
if @config.domain_context && !@config.domain_context.empty?
|
|
47
|
+
prompt += <<~PROMPT
|
|
48
|
+
|
|
49
|
+
Domain context:
|
|
50
|
+
#{@config.domain_context}
|
|
51
|
+
PROMPT
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
prompt += <<~PROMPT
|
|
55
|
+
|
|
56
|
+
Analyze the provided query workload data and produce a concise executive summary covering:
|
|
57
|
+
1. Read vs write ratio and overall workload characterization
|
|
58
|
+
2. Access patterns (point lookups, range scans, full table scans, aggregations)
|
|
59
|
+
3. Waste concentration — which queries examine many rows but return few
|
|
60
|
+
4. Top 3 highest-leverage changes that would improve overall performance
|
|
61
|
+
|
|
62
|
+
Respond with JSON: {"digest": "markdown-formatted workload analysis"}
|
|
63
|
+
PROMPT
|
|
64
|
+
|
|
65
|
+
prompt
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def user_prompt(formatted_stats, count)
|
|
69
|
+
<<~PROMPT
|
|
70
|
+
Top #{count} queries by total execution time:
|
|
71
|
+
|
|
72
|
+
#{formatted_stats}
|
|
73
|
+
PROMPT
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_stats(stats)
|
|
77
|
+
stats.map.with_index(1) do |s, i|
|
|
78
|
+
"#{i}. SQL: #{s[:sql]}\n " \
|
|
79
|
+
"calls=#{s[:calls]}, avg_time_ms=#{s[:avg_time_ms]}, " \
|
|
80
|
+
"rows_ratio=#{s[:rows_ratio]}, tmp_disk_tables=#{s[:tmp_disk_tables]}"
|
|
81
|
+
end.join("\n")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Service class for the Rails engine's GET /columns action. Takes a
|
|
7
|
+
# Core::Connection and the relevant configuration; returns a tagged
|
|
8
|
+
# Result struct that the controller maps to HTTP responses.
|
|
9
|
+
#
|
|
10
|
+
# Each status maps 1:1 to an HTTP status code:
|
|
11
|
+
# :ok → 200 with columns: array
|
|
12
|
+
# :blocked → 403 with error_message:
|
|
13
|
+
# :not_found → 404 with error_message:
|
|
14
|
+
#
|
|
15
|
+
# The adapter reads result.status and dispatches accordingly.
|
|
16
|
+
class Columns
|
|
17
|
+
Result = Struct.new(:status, :columns, :error_message, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def initialize(connection, blocked_tables:, masked_column_patterns:, default_columns:)
|
|
20
|
+
@connection = connection
|
|
21
|
+
@blocked_tables = blocked_tables
|
|
22
|
+
@masked_column_patterns = masked_column_patterns
|
|
23
|
+
@default_columns = default_columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(table:)
|
|
27
|
+
return blocked_result(table) if @blocked_tables.include?(table)
|
|
28
|
+
return not_found_result(table) unless @connection.tables.include?(table)
|
|
29
|
+
|
|
30
|
+
defaults = @default_columns[table] || []
|
|
31
|
+
visible = @connection.columns_for(table).reject do |col|
|
|
32
|
+
SqlValidator.masked_column?(col.name, @masked_column_patterns)
|
|
33
|
+
end
|
|
34
|
+
formatted = visible.map do |col|
|
|
35
|
+
{
|
|
36
|
+
name: col.name,
|
|
37
|
+
type: col.type.to_s,
|
|
38
|
+
default: defaults.empty? || defaults.include?(col.name),
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Result.new(status: :ok, columns: formatted)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def blocked_result(table)
|
|
48
|
+
Result.new(
|
|
49
|
+
status: :blocked,
|
|
50
|
+
error_message: "Table '#{table}' is not available for querying.",
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def not_found_result(table)
|
|
55
|
+
Result.new(
|
|
56
|
+
status: :not_found,
|
|
57
|
+
error_message: "Table '#{table}' does not exist.",
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module SqlGenius
|
|
6
|
+
module Core
|
|
7
|
+
module Analysis
|
|
8
|
+
# Detects indexes whose columns are a left-prefix of another index on
|
|
9
|
+
# the same table (meaning the shorter index is redundant — the longer
|
|
10
|
+
# one can satisfy the same queries). Preserves unique indexes: a unique
|
|
11
|
+
# index is never flagged as redundant when only covered by a non-unique
|
|
12
|
+
# index.
|
|
13
|
+
#
|
|
14
|
+
# Takes a Core::Connection plus a list of tables to exclude from the
|
|
15
|
+
# scan. Returns an array of hashes describing each duplicate pair, with
|
|
16
|
+
# the (duplicate_index, covered_by_index) pair deduplicated across
|
|
17
|
+
# symmetrical relationships.
|
|
18
|
+
class DuplicateIndexes
|
|
19
|
+
def initialize(connection, blocked_tables:)
|
|
20
|
+
@connection = connection
|
|
21
|
+
@blocked_tables = blocked_tables
|
|
22
|
+
@builder = QueryBuilders.for(connection)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
duplicates = []
|
|
27
|
+
|
|
28
|
+
queryable_tables.each do |table|
|
|
29
|
+
indexes = @connection.indexes_for(table)
|
|
30
|
+
next if indexes.size < 2
|
|
31
|
+
|
|
32
|
+
indexes.each do |idx|
|
|
33
|
+
indexes.each do |other|
|
|
34
|
+
next if idx.name == other.name
|
|
35
|
+
next unless covers?(other, idx)
|
|
36
|
+
|
|
37
|
+
duplicates << {
|
|
38
|
+
table: table,
|
|
39
|
+
duplicate_index: idx.name,
|
|
40
|
+
duplicate_columns: idx.columns,
|
|
41
|
+
covered_by_index: other.name,
|
|
42
|
+
covered_by_columns: other.columns,
|
|
43
|
+
unique: idx.unique,
|
|
44
|
+
drop_sql: @builder.drop_index_sql(table: table, index_name: idx.name),
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
deduplicate(duplicates)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def queryable_tables
|
|
56
|
+
@connection.tables - @blocked_tables
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True if `other` covers `idx` (idx's columns are a left-prefix of
|
|
60
|
+
# other's columns). Protects unique indexes from being covered by
|
|
61
|
+
# non-unique ones.
|
|
62
|
+
def covers?(other, idx)
|
|
63
|
+
return false if idx.columns.size > other.columns.size
|
|
64
|
+
return false unless other.columns.first(idx.columns.size) == idx.columns
|
|
65
|
+
return false if idx.unique && !other.unique
|
|
66
|
+
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deduplicate(duplicates)
|
|
71
|
+
seen = Set.new
|
|
72
|
+
duplicates.reject do |d|
|
|
73
|
+
key = [d[:table], [d[:duplicate_index], d[:covered_by_index]].sort].flatten.join(":")
|
|
74
|
+
if seen.include?(key)
|
|
75
|
+
true
|
|
76
|
+
else
|
|
77
|
+
seen.add(key)
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Fetches a single query's aggregated stats by its digest/queryid for the
|
|
7
|
+
# query detail page. Returns nil if the digest is not found in the
|
|
8
|
+
# statement-stats source for the current database.
|
|
9
|
+
#
|
|
10
|
+
# On MySQL/MariaDB this reads performance_schema.events_statements_summary_by_digest;
|
|
11
|
+
# on PostgreSQL it reads pg_stat_statements joined with pg_database.
|
|
12
|
+
class QueryHistory
|
|
13
|
+
def initialize(connection)
|
|
14
|
+
@connection = connection
|
|
15
|
+
@builder = QueryBuilders.for(connection)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(digest)
|
|
19
|
+
digest_str = digest.to_s
|
|
20
|
+
return if digest_str.empty?
|
|
21
|
+
|
|
22
|
+
sql = @builder.query_history(@connection, digest: digest_str)
|
|
23
|
+
result = @connection.exec_query(sql)
|
|
24
|
+
row = result.to_hashes.first
|
|
25
|
+
return unless row
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
sql: row["DIGEST_TEXT"] || row["digest_text"],
|
|
29
|
+
calls: (row["calls"] || row["CALLS"] || 0).to_i,
|
|
30
|
+
total_time_ms: (row["total_time_ms"] || 0).to_f,
|
|
31
|
+
avg_time_ms: (row["avg_time_ms"] || 0).to_f,
|
|
32
|
+
max_time_ms: (row["max_time_ms"] || 0).to_f,
|
|
33
|
+
rows_examined: (row["rows_examined"] || row["ROWS_EXAMINED"] || 0).to_i,
|
|
34
|
+
rows_sent: (row["rows_sent"] || row["ROWS_SENT"] || 0).to_i,
|
|
35
|
+
first_seen: (row["FIRST_SEEN"] || row["first_seen"]).to_s,
|
|
36
|
+
last_seen: (row["LAST_SEEN"] || row["last_seen"]).to_s,
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def digest_text_for(digest)
|
|
41
|
+
digest_str = digest.to_s
|
|
42
|
+
return if digest_str.empty?
|
|
43
|
+
|
|
44
|
+
sql = @builder.digest_text_lookup(@connection, digest: digest_str)
|
|
45
|
+
@connection.select_value(sql)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Top statements by a given sort dimension, sourced from MySQL's
|
|
7
|
+
# performance_schema.events_statements_summary_by_digest or PostgreSQL's
|
|
8
|
+
# pg_stat_statements (whichever the connected server provides). Returns
|
|
9
|
+
# an array of per-digest hashes with call counts, timing percentiles,
|
|
10
|
+
# row examine/sent ratios, and temp-table metadata.
|
|
11
|
+
#
|
|
12
|
+
# If the underlying stats source is not enabled, the SQL exec will
|
|
13
|
+
# raise — the caller decides how to render that.
|
|
14
|
+
class QueryStats
|
|
15
|
+
VALID_SORTS = ["total_time", "avg_time", "calls", "rows_examined"].freeze
|
|
16
|
+
MAX_LIMIT = 50
|
|
17
|
+
|
|
18
|
+
def initialize(connection)
|
|
19
|
+
@connection = connection
|
|
20
|
+
@builder = QueryBuilders.for(connection)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(sort: "total_time", limit: MAX_LIMIT)
|
|
24
|
+
order_clause = @builder.query_stats_order_clause(sort)
|
|
25
|
+
effective_limit = limit.to_i.clamp(1, MAX_LIMIT)
|
|
26
|
+
|
|
27
|
+
sql = @builder.query_stats(
|
|
28
|
+
@connection,
|
|
29
|
+
order_clause: order_clause,
|
|
30
|
+
limit: effective_limit,
|
|
31
|
+
include_digest: digest_column_available?,
|
|
32
|
+
)
|
|
33
|
+
result = @connection.exec_query(sql)
|
|
34
|
+
result.to_hashes.map { |row| transform(row) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def transform(row)
|
|
40
|
+
digest = (row["DIGEST_TEXT"] || row["digest_text"] || "").to_s
|
|
41
|
+
calls = (row["calls"] || row["CALLS"] || 0).to_i
|
|
42
|
+
rows_examined = (row["rows_examined"] || row["ROWS_EXAMINED"] || 0).to_i
|
|
43
|
+
rows_sent = (row["rows_sent"] || row["ROWS_SENT"] || 0).to_i
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
digest: (row["DIGEST"] || row["digest"] || "").to_s,
|
|
47
|
+
sql: truncate(digest, 500),
|
|
48
|
+
calls: calls,
|
|
49
|
+
total_time_ms: (row["total_time_ms"] || 0).to_f,
|
|
50
|
+
avg_time_ms: (row["avg_time_ms"] || 0).to_f,
|
|
51
|
+
max_time_ms: (row["max_time_ms"] || 0).to_f,
|
|
52
|
+
rows_examined: rows_examined,
|
|
53
|
+
rows_sent: rows_sent,
|
|
54
|
+
rows_ratio: rows_sent.positive? ? (rows_examined.to_f / rows_sent).round(1) : 0,
|
|
55
|
+
tmp_disk_tables: (row["tmp_disk_tables"] || row["TMP_DISK_TABLES"] || 0).to_i,
|
|
56
|
+
sort_rows: (row["sort_rows"] || row["SORT_ROWS"] || 0).to_i,
|
|
57
|
+
first_seen: row["FIRST_SEEN"] || row["first_seen"],
|
|
58
|
+
last_seen: row["LAST_SEEN"] || row["last_seen"],
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def truncate(string, max)
|
|
63
|
+
return string if string.length <= max
|
|
64
|
+
|
|
65
|
+
"#{string[0, max - 3]}..."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def digest_column_available?
|
|
69
|
+
return @digest_available if defined?(@digest_available)
|
|
70
|
+
|
|
71
|
+
@digest_available = @builder.digest_column_available?(@connection)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|