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,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Dashboard snapshot of server state. Dispatches to a dialect-specific
7
+ # implementation that produces a uniform nested hash with four
8
+ # top-level sections: server, connections, innodb, queries.
9
+ #
10
+ # On PostgreSQL the `innodb` section is populated with the closest
11
+ # equivalents (shared_buffers as buffer pool size, blks_hit/blks_read
12
+ # ratio as buffer pool hit rate). Lock-related fields fall back to 0
13
+ # since there is no direct equivalent of InnoDB row lock waits.
14
+ class ServerOverview
15
+ def initialize(connection)
16
+ @connection = connection
17
+ end
18
+
19
+ def call
20
+ impl_for(@connection).call
21
+ end
22
+
23
+ private
24
+
25
+ def impl_for(connection)
26
+ if connection.server_version.postgresql?
27
+ Postgresql.new(connection)
28
+ else
29
+ Mysql.new(connection)
30
+ end
31
+ end
32
+
33
+ # MySQL / MariaDB implementation. Combines SHOW GLOBAL STATUS, SHOW
34
+ # GLOBAL VARIABLES, and SELECT VERSION() into the dashboard hash.
35
+ class Mysql
36
+ def initialize(connection)
37
+ @connection = connection
38
+ end
39
+
40
+ def call
41
+ status = load_status
42
+ vars = load_variables
43
+ version = @connection.select_value("SELECT VERSION()")
44
+
45
+ uptime_seconds = status["Uptime"].to_i
46
+ {
47
+ server: server_block(version, uptime_seconds),
48
+ connections: connections_block(status, vars),
49
+ innodb: innodb_block(status, vars),
50
+ queries: queries_block(status, uptime_seconds),
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ def load_status
57
+ result = @connection.exec_query("SHOW GLOBAL STATUS")
58
+ result.to_hashes.each_with_object({}) do |row, acc|
59
+ name = (row["Variable_name"] || row["variable_name"]).to_s
60
+ value = (row["Value"] || row["value"]).to_s
61
+ acc[name] = value
62
+ end
63
+ end
64
+
65
+ def load_variables
66
+ result = @connection.exec_query("SHOW GLOBAL VARIABLES")
67
+ result.to_hashes.each_with_object({}) do |row, acc|
68
+ name = (row["Variable_name"] || row["variable_name"]).to_s
69
+ value = (row["Value"] || row["value"]).to_s
70
+ acc[name] = value
71
+ end
72
+ end
73
+
74
+ def server_block(version, uptime_seconds)
75
+ days = uptime_seconds / 86_400
76
+ hours = (uptime_seconds % 86_400) / 3600
77
+ minutes = (uptime_seconds % 3600) / 60
78
+
79
+ {
80
+ version: version,
81
+ uptime: "#{days}d #{hours}h #{minutes}m",
82
+ uptime_seconds: uptime_seconds,
83
+ }
84
+ end
85
+
86
+ def connections_block(status, vars)
87
+ max_conn = vars["max_connections"].to_i
88
+ current_conn = status["Threads_connected"].to_i
89
+ usage_pct = max_conn.positive? ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
90
+
91
+ {
92
+ max: max_conn,
93
+ current: current_conn,
94
+ usage_pct: usage_pct,
95
+ threads_running: status["Threads_running"].to_i,
96
+ threads_cached: status["Threads_cached"].to_i,
97
+ threads_created: status["Threads_created"].to_i,
98
+ aborted_connects: status["Aborted_connects"].to_i,
99
+ aborted_clients: status["Aborted_clients"].to_i,
100
+ max_used: status["Max_used_connections"].to_i,
101
+ }
102
+ end
103
+
104
+ def innodb_block(status, vars)
105
+ buffer_pool_bytes = vars["innodb_buffer_pool_size"].to_i
106
+ buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
107
+
108
+ reads = status["Innodb_buffer_pool_read_requests"].to_f
109
+ disk_reads = status["Innodb_buffer_pool_reads"].to_f
110
+ hit_rate = reads.positive? ? (((reads - disk_reads) / reads) * 100).round(2) : 0
111
+
112
+ {
113
+ buffer_pool_mb: buffer_pool_mb,
114
+ buffer_pool_hit_rate: hit_rate,
115
+ buffer_pool_pages_dirty: status["Innodb_buffer_pool_pages_dirty"].to_i,
116
+ buffer_pool_pages_free: status["Innodb_buffer_pool_pages_free"].to_i,
117
+ buffer_pool_pages_total: status["Innodb_buffer_pool_pages_total"].to_i,
118
+ row_lock_waits: status["Innodb_row_lock_waits"].to_i,
119
+ row_lock_time_ms: status["Innodb_row_lock_time"].to_f.round(0),
120
+ }
121
+ end
122
+
123
+ def queries_block(status, uptime_seconds)
124
+ tmp_tables = status["Created_tmp_tables"].to_i
125
+ tmp_disk_tables = status["Created_tmp_disk_tables"].to_i
126
+ tmp_disk_pct = tmp_tables.positive? ? ((tmp_disk_tables.to_f / tmp_tables) * 100).round(1) : 0
127
+
128
+ questions = status["Questions"].to_i
129
+ qps = uptime_seconds.positive? ? (questions.to_f / uptime_seconds).round(1) : 0
130
+
131
+ {
132
+ questions: questions,
133
+ qps: qps,
134
+ slow_queries: status["Slow_queries"].to_i,
135
+ tmp_tables: tmp_tables,
136
+ tmp_disk_tables: tmp_disk_tables,
137
+ tmp_disk_pct: tmp_disk_pct,
138
+ select_full_join: status["Select_full_join"].to_i,
139
+ sort_merge_passes: status["Sort_merge_passes"].to_i,
140
+ }
141
+ end
142
+ end
143
+
144
+ # PostgreSQL implementation. Reads connection/database stats from
145
+ # pg_stat_activity and pg_stat_database; reads tunable settings via
146
+ # pg_settings; populates the `innodb` block with shared_buffers and
147
+ # the buffer cache hit rate so the existing UI continues to render.
148
+ class Postgresql
149
+ def initialize(connection)
150
+ @connection = connection
151
+ end
152
+
153
+ def call
154
+ version = @connection.select_value("SELECT version()").to_s
155
+ uptime_seconds = @connection.select_value(
156
+ "SELECT EXTRACT(EPOCH FROM (now() - pg_postmaster_start_time()))::bigint",
157
+ ).to_i
158
+
159
+ {
160
+ server: server_block(version, uptime_seconds),
161
+ connections: connections_block,
162
+ innodb: innodb_block,
163
+ queries: queries_block(uptime_seconds),
164
+ }
165
+ end
166
+
167
+ private
168
+
169
+ def server_block(version, uptime_seconds)
170
+ days = uptime_seconds / 86_400
171
+ hours = (uptime_seconds % 86_400) / 3600
172
+ minutes = (uptime_seconds % 3600) / 60
173
+
174
+ {
175
+ version: version,
176
+ uptime: "#{days}d #{hours}h #{minutes}m",
177
+ uptime_seconds: uptime_seconds,
178
+ }
179
+ end
180
+
181
+ def connections_block
182
+ max_conn = setting_int("max_connections")
183
+ current_conn = @connection.select_value("SELECT count(*) FROM pg_stat_activity").to_i
184
+ running = @connection.select_value(
185
+ "SELECT count(*) FROM pg_stat_activity WHERE state = 'active'",
186
+ ).to_i
187
+ usage_pct = max_conn.positive? ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
188
+
189
+ # PostgreSQL doesn't track aborted-connect counts the way MySQL
190
+ # does, and pg_stat_database.xact_rollback (rolled-back
191
+ # transactions) is a different concept — surface it on the
192
+ # PG-specific path elsewhere if needed, rather than mislabeling
193
+ # it as "Aborted Clients" here.
194
+ {
195
+ max: max_conn,
196
+ current: current_conn,
197
+ usage_pct: usage_pct,
198
+ threads_running: running,
199
+ threads_cached: 0,
200
+ threads_created: 0,
201
+ aborted_connects: 0,
202
+ aborted_clients: 0,
203
+ max_used: 0,
204
+ }
205
+ end
206
+
207
+ def innodb_block
208
+ buffer_pool_bytes = setting_bytes("shared_buffers")
209
+ buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
210
+
211
+ db_stats = current_db_stats
212
+ reads = db_stats[:blks_hit].to_f + db_stats[:blks_read].to_f
213
+ hit_rate = reads.positive? ? ((db_stats[:blks_hit].to_f / reads) * 100).round(2) : 0
214
+
215
+ {
216
+ buffer_pool_mb: buffer_pool_mb,
217
+ buffer_pool_hit_rate: hit_rate,
218
+ buffer_pool_pages_dirty: 0,
219
+ buffer_pool_pages_free: 0,
220
+ buffer_pool_pages_total: 0,
221
+ row_lock_waits: db_stats[:deadlocks],
222
+ row_lock_time_ms: 0,
223
+ }
224
+ end
225
+
226
+ def queries_block(uptime_seconds)
227
+ db_stats = current_db_stats
228
+ questions = db_stats[:xact_commit].to_i + db_stats[:xact_rollback].to_i
229
+ qps = uptime_seconds.positive? ? (questions.to_f / uptime_seconds).round(1) : 0
230
+ # PG's `temp_files` is a single count of files written when sorts/
231
+ # hashes spilled out of work_mem. Surface it on both sides of the
232
+ # fraction with a 0% ratio so the dashboard doesn't flag a red
233
+ # "100% spilled to disk" badge that the metric doesn't actually
234
+ # mean on PostgreSQL.
235
+ temp_files = db_stats[:temp_files]
236
+
237
+ {
238
+ questions: questions,
239
+ qps: qps,
240
+ slow_queries: 0,
241
+ tmp_tables: temp_files,
242
+ tmp_disk_tables: temp_files,
243
+ tmp_disk_pct: 0,
244
+ select_full_join: 0,
245
+ sort_merge_passes: 0,
246
+ }
247
+ end
248
+
249
+ def current_db_stats
250
+ @current_db_stats ||= begin
251
+ result = @connection.exec_query(<<~SQL)
252
+ SELECT
253
+ COALESCE(xact_commit, 0) AS xact_commit,
254
+ COALESCE(xact_rollback, 0) AS xact_rollback,
255
+ COALESCE(blks_read, 0) AS blks_read,
256
+ COALESCE(blks_hit, 0) AS blks_hit,
257
+ COALESCE(temp_files, 0) AS temp_files,
258
+ COALESCE(deadlocks, 0) AS deadlocks
259
+ FROM pg_stat_database
260
+ WHERE datname = #{@connection.quote(@connection.current_database)}
261
+ SQL
262
+ row = result.to_hashes.first || {}
263
+ {
264
+ xact_commit: row["xact_commit"].to_i,
265
+ xact_rollback: row["xact_rollback"].to_i,
266
+ blks_read: row["blks_read"].to_i,
267
+ blks_hit: row["blks_hit"].to_i,
268
+ temp_files: row["temp_files"].to_i,
269
+ deadlocks: row["deadlocks"].to_i,
270
+ }
271
+ end
272
+ end
273
+
274
+ def setting_int(name)
275
+ @connection.select_value(
276
+ "SELECT setting FROM pg_settings WHERE name = #{@connection.quote(name)}",
277
+ ).to_i
278
+ end
279
+
280
+ # shared_buffers / work_mem etc. report via current_setting() with
281
+ # their configured unit suffix (e.g. "128MB"). pg_size_bytes()
282
+ # resolves that to a raw byte count. Available since PG 9.6.
283
+ def setting_bytes(name)
284
+ @connection.select_value(
285
+ "SELECT pg_size_bytes(current_setting(#{@connection.quote(name)}))",
286
+ ).to_i
287
+ rescue StandardError
288
+ 0
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Background sampler that periodically queries performance_schema for the
7
+ # top 50 digests by SUM_TIMER_WAIT, computes per-interval deltas, and
8
+ # records snapshots into a StatsHistory ring buffer.
9
+ #
10
+ # The +connection_provider+ is a callable (lambda/proc) that returns a
11
+ # Core::Connection on each invocation. This allows each adapter to supply
12
+ # its own connection strategy:
13
+ #
14
+ # Rails: -> { ActiveRecordAdapter.new(ActiveRecord::Base.connection) }
15
+ # Desktop: -> { session.checkout { |c| c } }
16
+ class StatsCollector
17
+ DEFAULT_INTERVAL = 60
18
+ STOP_JOIN_TIMEOUT = 5
19
+ TOP_N = 50
20
+
21
+ def initialize(connection_provider:, history:, interval: DEFAULT_INTERVAL)
22
+ @connection_provider = connection_provider
23
+ @history = history
24
+ @interval = interval
25
+ @mutex = Mutex.new
26
+ @cv = ConditionVariable.new
27
+ @stop_signal = false
28
+ @running = false
29
+ @thread = nil
30
+ @previous = {}
31
+ end
32
+
33
+ def start
34
+ return self if @running
35
+
36
+ @stop_signal = false
37
+ @running = true
38
+ @thread = Thread.new { run_loop }
39
+ self
40
+ end
41
+
42
+ def stop
43
+ @mutex.synchronize do
44
+ @stop_signal = true
45
+ @cv.signal
46
+ end
47
+ @thread&.join(STOP_JOIN_TIMEOUT)
48
+ @thread = nil
49
+ end
50
+
51
+ def running?
52
+ @running
53
+ end
54
+
55
+ private
56
+
57
+ def run_loop
58
+ loop do
59
+ tick
60
+ break if wait_or_stop(@interval)
61
+ end
62
+ rescue StandardError => e
63
+ warn("[SqlGenius] StatsCollector stopped: #{e.message}")
64
+ ensure
65
+ @running = false
66
+ end
67
+
68
+ def tick
69
+ connection = @connection_provider.call
70
+ result = connection.exec_query(build_sql(connection))
71
+ current = {}
72
+
73
+ result.to_hashes.each do |row|
74
+ digest_text = (row["DIGEST_TEXT"] || row["digest_text"]).to_s
75
+ next if digest_text.empty?
76
+
77
+ calls = (row["COUNT_STAR"] || row["count_star"]).to_i
78
+ total_time_ms = (row["total_time_ms"] || row["TOTAL_TIME_MS"] || 0).to_f
79
+
80
+ current[digest_text] = { calls: calls, total_time_ms: total_time_ms }
81
+
82
+ next unless @previous.key?(digest_text)
83
+
84
+ record_delta(digest_text, calls, total_time_ms)
85
+ end
86
+
87
+ @previous = current
88
+ end
89
+
90
+ def record_delta(digest_text, calls, total_time_ms)
91
+ prev = @previous[digest_text]
92
+ delta_calls = [calls - prev[:calls], 0].max
93
+ delta_total_ms = [(total_time_ms - prev[:total_time_ms]).round(1), 0.0].max
94
+
95
+ @history.record(digest_text, {
96
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
97
+ calls: delta_calls,
98
+ total_time_ms: delta_total_ms,
99
+ avg_time_ms: delta_calls.positive? ? (delta_total_ms / delta_calls).round(1) : 0.0,
100
+ })
101
+ end
102
+
103
+ def build_sql(connection)
104
+ QueryBuilders.for(connection).stats_snapshot(connection, limit: TOP_N)
105
+ end
106
+
107
+ def wait_or_stop(seconds)
108
+ @mutex.synchronize do
109
+ return true if @stop_signal
110
+
111
+ @cv.wait(@mutex, seconds)
112
+ @stop_signal
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Thread-safe in-memory ring buffer that stores per-digest query stats
7
+ # snapshots. Each digest key maps to an array of snapshots capped at
8
+ # +max_samples+. Oldest entries are dropped when the cap is reached.
9
+ class StatsHistory
10
+ DEFAULT_MAX_SAMPLES = 1440
11
+
12
+ def initialize(max_samples: DEFAULT_MAX_SAMPLES)
13
+ @max_samples = max_samples
14
+ @mutex = Mutex.new
15
+ @data = {}
16
+ end
17
+
18
+ def record(digest_text, snapshot)
19
+ @mutex.synchronize do
20
+ buf = (@data[digest_text] ||= [])
21
+ buf << snapshot
22
+ buf.shift if buf.length > @max_samples
23
+ end
24
+ end
25
+
26
+ def series_for(digest_text)
27
+ @mutex.synchronize do
28
+ (@data[digest_text] || []).dup
29
+ end
30
+ end
31
+
32
+ def digests
33
+ @mutex.synchronize { @data.keys.dup }
34
+ end
35
+
36
+ def clear
37
+ @mutex.synchronize { @data.clear }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Returns size/fragmentation metrics for each user table in the current
7
+ # database, plus an exact SELECT COUNT(*) for each table. Delegates SQL
8
+ # generation to the dialect-appropriate QueryBuilder so the same class
9
+ # works against MySQL/MariaDB (information_schema.tables) and PostgreSQL
10
+ # (pg_class + pg_total_relation_size).
11
+ #
12
+ # Takes a Core::Connection. Returns an array of hashes suitable for
13
+ # JSON rendering.
14
+ class TableSizes
15
+ def initialize(connection)
16
+ @connection = connection
17
+ @builder = QueryBuilders.for(connection)
18
+ end
19
+
20
+ def call
21
+ result = @connection.exec_query(@builder.table_sizes(@connection))
22
+
23
+ result.to_hashes.map do |row|
24
+ table_name = row["table_name"] || row["TABLE_NAME"]
25
+ row_count = begin
26
+ @connection.select_value("SELECT COUNT(*) FROM #{@connection.quote_table_name(table_name)}")
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ total_mb = (row["total_mb"] || 0).to_f
32
+ fragmented_mb = (row["fragmented_mb"] || 0).to_f
33
+
34
+ {
35
+ table: table_name,
36
+ rows: row_count,
37
+ engine: row["engine"] || row["ENGINE"],
38
+ collation: row["table_collation"] || row["TABLE_COLLATION"],
39
+ auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
40
+ updated_at: row["update_time"] || row["UPDATE_TIME"],
41
+ data_mb: (row["data_mb"] || 0).to_f,
42
+ index_mb: (row["index_mb"] || 0).to_f,
43
+ total_mb: total_mb,
44
+ fragmented_mb: fragmented_mb,
45
+ needs_optimize: total_mb.positive? && fragmented_mb > (total_mb * 0.1),
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Indexes whose scan count is at or below `min_scans` (default 0 — never
7
+ # scanned since the underlying stats source was last reset). On MySQL this
8
+ # reads performance_schema.table_io_waits_summary_by_index_usage; on
9
+ # PostgreSQL it reads pg_stat_user_indexes plus pg_relation_size for the
10
+ # index byte size.
11
+ #
12
+ # Returns a Result with:
13
+ # indexes — Array of per-index hashes (sorted by size DESC on PG,
14
+ # by write count DESC on MySQL); each carries a dialect-
15
+ # appropriate `drop_sql` and a `size_bytes` value (nil
16
+ # on MySQL where individual index sizes aren't cheap).
17
+ # stats_reset_at — Time the underlying stats source was last reset
18
+ # (PG only — pg_stat_database.stats_reset; nil on MySQL).
19
+ # min_scans — The scan threshold used for this call, echoed back so
20
+ # callers can display "indexes with ≤ N scans".
21
+ #
22
+ # Skips primary key indexes on both dialects, plus unique indexes (which
23
+ # are usually backing a constraint the application depends on). Raises if
24
+ # the underlying stats source is unavailable.
25
+ class UnusedIndexes
26
+ Result = Struct.new(:indexes, :stats_reset_at, :min_scans, keyword_init: true)
27
+
28
+ def initialize(connection, min_scans: 0)
29
+ @connection = connection
30
+ @builder = QueryBuilders.for(connection)
31
+ @min_scans = [min_scans.to_i, 0].max
32
+ end
33
+
34
+ def call
35
+ rows = @connection.exec_query(@builder.unused_indexes(@connection, min_scans: @min_scans)).to_hashes
36
+ Result.new(
37
+ indexes: rows.map { |row| transform(row) },
38
+ stats_reset_at: @builder.stats_reset_at(@connection),
39
+ min_scans: @min_scans,
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ def transform(row)
46
+ table = row["table_name"] || row["TABLE_NAME"]
47
+ index_name = row["index_name"] || row["INDEX_NAME"]
48
+ size_bytes = row["size_bytes"] || row["SIZE_BYTES"]
49
+ {
50
+ table: table,
51
+ index_name: index_name,
52
+ reads: (row["reads"] || row["READS"] || 0).to_i,
53
+ writes: (row["writes"] || row["WRITES"] || 0).to_i,
54
+ table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
55
+ size_bytes: size_bytes&.to_i,
56
+ drop_sql: @builder.drop_index_sql(table: table, index_name: index_name),
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Column metadata as returned by Core::Connection#columns_for. Mirrors
6
+ # the subset of ActiveRecord::ConnectionAdapters::Column that the
7
+ # analyses and AI services rely on.
8
+ class ColumnDefinition
9
+ attr_reader :name, :type, :sql_type, :null, :default, :primary_key
10
+
11
+ def initialize(name:, type:, sql_type:, null:, default:, primary_key:)
12
+ @name = name
13
+ @type = type
14
+ @sql_type = sql_type
15
+ @null = null
16
+ @default = default
17
+ @primary_key = primary_key
18
+ freeze
19
+ end
20
+
21
+ def null?
22
+ @null
23
+ end
24
+
25
+ def primary_key?
26
+ @primary_key
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sql_genius/core"
4
+
5
+ module SqlGenius
6
+ module Core
7
+ module Connection
8
+ # Wraps an ActiveRecord::Base.connection and implements the
9
+ # Core::Connection contract.
10
+ class ActiveRecordAdapter
11
+ def initialize(ar_connection)
12
+ @ar = ar_connection
13
+ end
14
+
15
+ def exec_query(sql, binds: [])
16
+ _ = binds
17
+ ar_result = @ar.exec_query(sql)
18
+ Core::Result.new(columns: ar_result.columns, rows: ar_result.rows)
19
+ end
20
+
21
+ def select_value(sql)
22
+ @ar.select_value(sql)
23
+ end
24
+
25
+ def server_version
26
+ @server_version ||= Core::ServerInfo.parse(@ar.select_value("SELECT VERSION()").to_s)
27
+ end
28
+
29
+ def current_database
30
+ @ar.current_database
31
+ end
32
+
33
+ def quote(value)
34
+ @ar.quote(value)
35
+ end
36
+
37
+ def quote_table_name(name)
38
+ @ar.quote_table_name(name)
39
+ end
40
+
41
+ def tables
42
+ @ar.tables
43
+ end
44
+
45
+ def columns_for(table)
46
+ pk = @ar.primary_key(table)
47
+ @ar.columns(table).map do |c|
48
+ Core::ColumnDefinition.new(
49
+ name: c.name,
50
+ type: c.type,
51
+ sql_type: c.sql_type,
52
+ null: c.null,
53
+ default: c.default,
54
+ primary_key: c.name == pk,
55
+ )
56
+ end
57
+ end
58
+
59
+ def indexes_for(table)
60
+ @ar.indexes(table).map do |idx|
61
+ Core::IndexDefinition.new(name: idx.name, columns: idx.columns, unique: idx.unique)
62
+ end
63
+ end
64
+
65
+ def primary_key(table)
66
+ @ar.primary_key(table)
67
+ end
68
+
69
+ def close
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end