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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +195 -0
  3. data/LICENSE.txt +65 -0
  4. data/README.md +178 -0
  5. data/Rakefile +8 -0
  6. data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
  7. data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
  8. data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
  9. data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
  10. data/app/controllers/sql_genius/base_controller.rb +29 -0
  11. data/app/controllers/sql_genius/queries_controller.rb +94 -0
  12. data/app/views/layouts/sql_genius/application.html.erb +285 -0
  13. data/config/routes.rb +34 -0
  14. data/docs/guides/ai-features.md +115 -0
  15. data/docs/guides/getting-started-rails.md +118 -0
  16. data/docs/guides/ssh-tunnel-connections.md +151 -0
  17. data/docs/screenshots/ai_tools.png +0 -0
  18. data/docs/screenshots/dashboard.png +0 -0
  19. data/docs/screenshots/duplicate_indexes.png +0 -0
  20. data/docs/screenshots/query_explore.png +0 -0
  21. data/docs/screenshots/query_stats.png +0 -0
  22. data/docs/screenshots/server.png +0 -0
  23. data/docs/screenshots/table_sizes.png +0 -0
  24. data/lib/generators/sql_genius/install/install_generator.rb +19 -0
  25. data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
  26. data/lib/sql_genius/configuration.rb +114 -0
  27. data/lib/sql_genius/core/ai/client.rb +155 -0
  28. data/lib/sql_genius/core/ai/config.rb +47 -0
  29. data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
  30. data/lib/sql_genius/core/ai/describe_query.rb +41 -0
  31. data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
  32. data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
  33. data/lib/sql_genius/core/ai/index_planner.rb +91 -0
  34. data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
  35. data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
  36. data/lib/sql_genius/core/ai/optimization.rb +81 -0
  37. data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
  38. data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
  39. data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
  40. data/lib/sql_genius/core/ai/schema_review.rb +46 -0
  41. data/lib/sql_genius/core/ai/suggestion.rb +74 -0
  42. data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
  43. data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
  44. data/lib/sql_genius/core/analysis/columns.rb +63 -0
  45. data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
  46. data/lib/sql_genius/core/analysis/query_history.rb +50 -0
  47. data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
  48. data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
  49. data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
  50. data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
  51. data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
  52. data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
  53. data/lib/sql_genius/core/column_definition.rb +30 -0
  54. data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
  55. data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
  56. data/lib/sql_genius/core/connection.rb +37 -0
  57. data/lib/sql_genius/core/execution_result.rb +27 -0
  58. data/lib/sql_genius/core/index_definition.rb +23 -0
  59. data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
  60. data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
  61. data/lib/sql_genius/core/query_builders.rb +27 -0
  62. data/lib/sql_genius/core/query_explainer.rb +113 -0
  63. data/lib/sql_genius/core/query_runner/config.rb +21 -0
  64. data/lib/sql_genius/core/query_runner.rb +123 -0
  65. data/lib/sql_genius/core/result.rb +43 -0
  66. data/lib/sql_genius/core/server_info.rb +54 -0
  67. data/lib/sql_genius/core/sql_validator.rb +149 -0
  68. data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
  69. data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
  70. data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
  71. data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
  72. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
  73. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
  74. data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
  75. data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
  76. data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
  77. data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
  78. data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
  79. data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
  80. data/lib/sql_genius/core.rb +72 -0
  81. data/lib/sql_genius/engine.rb +31 -0
  82. data/lib/sql_genius/slow_query_monitor.rb +43 -0
  83. data/lib/sql_genius/version.rb +5 -0
  84. data/lib/sql_genius.rb +29 -0
  85. data/sql_genius.gemspec +47 -0
  86. 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