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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module Connection
6
+ # In-memory fake connection used by core specs. Supports stubbing
7
+ # queries by regex and returning canned Core::Result values, plus
8
+ # stubbing metadata methods. See spec/sql_genius/core/connection/
9
+ # fake_adapter_spec.rb for the full surface.
10
+ class FakeAdapter
11
+ class NoStubError < StandardError; end
12
+
13
+ def initialize
14
+ @stubs = []
15
+ @tables = []
16
+ @columns_for = {}
17
+ @indexes_for = {}
18
+ @primary_keys = {}
19
+ @server_version = "8.0.35"
20
+ @current_database = "test_db"
21
+ end
22
+
23
+ # ----- stub registration -----
24
+
25
+ def stub_query(pattern, columns: [], rows: [], raises: nil)
26
+ @stubs << { pattern: pattern, columns: columns, rows: rows, raises: raises }
27
+ end
28
+
29
+ def stub_server_version(version)
30
+ @server_version = version
31
+ end
32
+
33
+ def stub_current_database(name)
34
+ @current_database = name
35
+ end
36
+
37
+ def stub_tables(list)
38
+ @tables = list
39
+ end
40
+
41
+ def stub_columns_for(table, columns)
42
+ @columns_for[table] = columns
43
+ end
44
+
45
+ def stub_indexes_for(table, indexes)
46
+ @indexes_for[table] = indexes
47
+ end
48
+
49
+ def stub_primary_key(table, name)
50
+ @primary_keys[table] = name
51
+ end
52
+
53
+ # ----- contract -----
54
+
55
+ def exec_query(sql, binds: [])
56
+ _ = binds
57
+ stub = @stubs.find { |s| s[:pattern] =~ sql }
58
+ raise NoStubError, "No stub matched SQL: #{sql}" unless stub
59
+ raise stub[:raises] if stub[:raises]
60
+
61
+ Result.new(columns: stub[:columns], rows: stub[:rows])
62
+ end
63
+
64
+ def select_value(sql)
65
+ result = exec_query(sql)
66
+ return if result.empty?
67
+
68
+ result.rows.first&.first
69
+ end
70
+
71
+ def server_version
72
+ ServerInfo.parse(@server_version)
73
+ end
74
+
75
+ attr_reader :current_database
76
+
77
+ def quote(value)
78
+ case value
79
+ when nil then "NULL"
80
+ when Integer, Float then value.to_s
81
+ when String then "'#{value.gsub("'", "''")}'"
82
+ else "'#{value.to_s.gsub("'", "''")}'"
83
+ end
84
+ end
85
+
86
+ def quote_table_name(name)
87
+ if server_version.postgresql?
88
+ %("#{name.to_s.gsub('"', '""')}")
89
+ else
90
+ "`#{name}`"
91
+ end
92
+ end
93
+
94
+ attr_reader :tables
95
+
96
+ def columns_for(table)
97
+ @columns_for.fetch(table, [])
98
+ end
99
+
100
+ def indexes_for(table)
101
+ @indexes_for.fetch(table, [])
102
+ end
103
+
104
+ def primary_key(table)
105
+ @primary_keys[table]
106
+ end
107
+
108
+ def close
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Connection abstraction. This module is a namespace for concrete
6
+ # adapters plus documentation of the contract every adapter must
7
+ # satisfy. It is NOT meant to be included as a mixin; Ruby has no
8
+ # interface enforcement. Tests exercise the contract via duck-typing
9
+ # against the real adapters and the FakeAdapter test helper.
10
+ #
11
+ # Implementing adapters:
12
+ # SqlGenius::Core::Connection::FakeAdapter — used by tests
13
+ # SqlGenius::Core::Connection::ActiveRecordAdapter — wraps ActiveRecord::Base.connection
14
+ #
15
+ # Contract (every adapter must implement):
16
+ #
17
+ # #exec_query(sql) -> Core::Result
18
+ # #select_value(sql) -> Object (first column of first row, or nil)
19
+ # #server_version -> Core::ServerInfo
20
+ # #current_database -> String
21
+ # #quote(value) -> String (SQL-escaped value)
22
+ # #quote_table_name(name) -> String (dialect-quoted identifier:
23
+ # backticks for MySQL/MariaDB,
24
+ # double-quotes for PostgreSQL)
25
+ # #tables -> Array<String>
26
+ # #columns_for(table) -> Array<Core::ColumnDefinition>
27
+ # #indexes_for(table) -> Array<Core::IndexDefinition>
28
+ # #primary_key(table) -> String or nil
29
+ # #close -> nil
30
+ #
31
+ # Adapters may implement additional methods for efficiency, but any
32
+ # core code that depends on the connection must only call methods
33
+ # defined in this contract.
34
+ module Connection
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Immutable frozen value object returned from Core::QueryRunner#run.
6
+ # Contains the executed columns and (possibly masked) rows plus runtime
7
+ # metrics: row count, wall-clock execution time in milliseconds, and a
8
+ # truncated flag indicating whether the row count reached the applied
9
+ # LIMIT.
10
+ #
11
+ # This is distinct from Core::Result (which models a plain query result
12
+ # shape) because QueryRunner returns runtime metadata that plain results
13
+ # don't carry.
14
+ class ExecutionResult
15
+ attr_reader :columns, :rows, :row_count, :execution_time_ms, :truncated
16
+
17
+ def initialize(columns:, rows:, execution_time_ms:, truncated:)
18
+ @columns = columns.freeze
19
+ @rows = rows.freeze
20
+ @row_count = rows.length
21
+ @execution_time_ms = execution_time_ms
22
+ @truncated = truncated
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Index metadata as returned by Core::Connection#indexes_for. Mirrors
6
+ # the subset of ActiveRecord::ConnectionAdapters::IndexDefinition that
7
+ # the analyses rely on.
8
+ class IndexDefinition
9
+ attr_reader :name, :columns, :unique
10
+
11
+ def initialize(name:, columns:, unique:)
12
+ @name = name
13
+ @columns = columns.freeze
14
+ @unique = unique
15
+ freeze
16
+ end
17
+
18
+ def unique?
19
+ @unique
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module QueryBuilders
6
+ # MySQL / MariaDB query builder. Contains all SQL previously embedded
7
+ # in the Analysis classes prior to PostgreSQL support being added.
8
+ module Mysql
9
+ QUERY_STATS_NOISE_FILTERS = <<~SQL
10
+ DIGEST_TEXT NOT LIKE 'EXPLAIN%'
11
+ AND DIGEST_TEXT NOT LIKE '%`information_schema`%'
12
+ AND DIGEST_TEXT NOT LIKE '%`performance_schema`%'
13
+ AND DIGEST_TEXT NOT LIKE '%information_schema.%'
14
+ AND DIGEST_TEXT NOT LIKE '%performance_schema.%'
15
+ AND DIGEST_TEXT NOT LIKE 'SHOW %'
16
+ AND DIGEST_TEXT NOT LIKE 'SET STATEMENT %'
17
+ AND DIGEST_TEXT NOT LIKE 'SELECT VERSION ( )%'
18
+ AND DIGEST_TEXT NOT LIKE 'SELECT @@%'
19
+ SQL
20
+
21
+ extend self
22
+
23
+ def table_sizes(connection)
24
+ <<~SQL
25
+ SELECT
26
+ table_name,
27
+ engine,
28
+ table_collation,
29
+ auto_increment,
30
+ update_time,
31
+ ROUND(data_length / 1024 / 1024, 2) AS data_mb,
32
+ ROUND(index_length / 1024 / 1024, 2) AS index_mb,
33
+ ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
34
+ ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
35
+ FROM information_schema.tables
36
+ WHERE table_schema = #{connection.quote(connection.current_database)}
37
+ AND table_type = 'BASE TABLE'
38
+ ORDER BY (data_length + index_length) DESC
39
+ SQL
40
+ end
41
+
42
+ def query_stats(connection, order_clause:, limit:, include_digest:)
43
+ digest_col = include_digest ? "DIGEST," : ""
44
+ <<~SQL
45
+ SELECT
46
+ #{digest_col}
47
+ DIGEST_TEXT,
48
+ COUNT_STAR AS calls,
49
+ ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_time_ms,
50
+ ROUND(AVG_TIMER_WAIT / 1000000000, 1) AS avg_time_ms,
51
+ ROUND(MAX_TIMER_WAIT / 1000000000, 1) AS max_time_ms,
52
+ SUM_ROWS_EXAMINED AS rows_examined,
53
+ SUM_ROWS_SENT AS rows_sent,
54
+ SUM_CREATED_TMP_DISK_TABLES AS tmp_disk_tables,
55
+ SUM_SORT_ROWS AS sort_rows,
56
+ FIRST_SEEN,
57
+ LAST_SEEN
58
+ FROM performance_schema.events_statements_summary_by_digest
59
+ WHERE SCHEMA_NAME = #{connection.quote(connection.current_database)}
60
+ AND DIGEST_TEXT IS NOT NULL
61
+ AND #{QUERY_STATS_NOISE_FILTERS.strip}
62
+ ORDER BY #{order_clause}
63
+ LIMIT #{limit}
64
+ SQL
65
+ end
66
+
67
+ def query_stats_order_clause(sort)
68
+ case sort
69
+ when "total_time" then "SUM_TIMER_WAIT DESC"
70
+ when "avg_time" then "AVG_TIMER_WAIT DESC"
71
+ when "calls" then "COUNT_STAR DESC"
72
+ when "rows_examined" then "SUM_ROWS_EXAMINED DESC"
73
+ else "SUM_TIMER_WAIT DESC"
74
+ end
75
+ end
76
+
77
+ def stats_snapshot(connection, limit:)
78
+ <<~SQL
79
+ SELECT
80
+ DIGEST_TEXT,
81
+ COUNT_STAR,
82
+ ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_time_ms
83
+ FROM performance_schema.events_statements_summary_by_digest
84
+ WHERE SCHEMA_NAME = #{connection.quote(connection.current_database)}
85
+ AND DIGEST_TEXT IS NOT NULL
86
+ AND #{QUERY_STATS_NOISE_FILTERS.strip}
87
+ ORDER BY SUM_TIMER_WAIT DESC
88
+ LIMIT #{limit}
89
+ SQL
90
+ end
91
+
92
+ def unused_indexes(connection, min_scans: 0)
93
+ threshold = [min_scans.to_i, 0].max
94
+ <<~SQL
95
+ SELECT
96
+ s.OBJECT_SCHEMA AS table_schema,
97
+ s.OBJECT_NAME AS table_name,
98
+ s.INDEX_NAME AS index_name,
99
+ s.COUNT_READ AS `reads`,
100
+ s.COUNT_WRITE AS `writes`,
101
+ t.TABLE_ROWS AS table_rows,
102
+ NULL AS size_bytes
103
+ FROM performance_schema.table_io_waits_summary_by_index_usage s
104
+ JOIN information_schema.tables t
105
+ ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
106
+ WHERE s.OBJECT_SCHEMA = #{connection.quote(connection.current_database)}
107
+ AND s.INDEX_NAME IS NOT NULL
108
+ AND s.INDEX_NAME != 'PRIMARY'
109
+ AND s.COUNT_READ <= #{threshold}
110
+ ORDER BY s.COUNT_WRITE DESC
111
+ SQL
112
+ end
113
+
114
+ # MySQL's table_io_waits counters track since server start with no
115
+ # cheap way to surface that timestamp at query time, so we return nil
116
+ # and let the dashboard fall back to "since server restart" wording.
117
+ def stats_reset_at(_connection)
118
+ nil
119
+ end
120
+
121
+ def drop_index_sql(table:, index_name:)
122
+ "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;"
123
+ end
124
+
125
+ def query_history(connection, digest:)
126
+ quoted_digest = connection.quote(digest)
127
+ quoted_db = connection.quote(connection.current_database)
128
+ <<~SQL
129
+ SELECT DIGEST_TEXT,
130
+ COUNT_STAR AS calls,
131
+ ROUND(SUM_TIMER_WAIT / 1000000000.0, 2) AS total_time_ms,
132
+ ROUND(AVG_TIMER_WAIT / 1000000000.0, 2) AS avg_time_ms,
133
+ ROUND(MAX_TIMER_WAIT / 1000000000.0, 2) AS max_time_ms,
134
+ SUM_ROWS_EXAMINED AS rows_examined,
135
+ SUM_ROWS_SENT AS rows_sent,
136
+ FIRST_SEEN,
137
+ LAST_SEEN
138
+ FROM performance_schema.events_statements_summary_by_digest
139
+ WHERE DIGEST = #{quoted_digest}
140
+ AND SCHEMA_NAME = #{quoted_db}
141
+ LIMIT 1
142
+ SQL
143
+ end
144
+
145
+ def digest_text_lookup(connection, digest:)
146
+ quoted_digest = connection.quote(digest)
147
+ <<~SQL
148
+ SELECT DIGEST_TEXT
149
+ FROM performance_schema.events_statements_summary_by_digest
150
+ WHERE DIGEST = #{quoted_digest}
151
+ LIMIT 1
152
+ SQL
153
+ end
154
+
155
+ def digest_column_available?(connection)
156
+ result = connection.exec_query(
157
+ "SELECT COLUMN_NAME FROM information_schema.COLUMNS " \
158
+ "WHERE TABLE_SCHEMA = 'performance_schema' " \
159
+ "AND TABLE_NAME = 'events_statements_summary_by_digest' " \
160
+ "AND COLUMN_NAME = 'DIGEST'",
161
+ )
162
+ !result.rows.empty?
163
+ rescue StandardError
164
+ false
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module QueryBuilders
6
+ # PostgreSQL query builder. Produces SQL that returns the same
7
+ # column-name contract as the MySQL builder so the Analysis classes
8
+ # can stay dialect-agnostic after picking a builder.
9
+ #
10
+ # Caveats:
11
+ # - query_stats and stats_snapshot require the pg_stat_statements
12
+ # extension. If it's not installed, the query will raise; the
13
+ # Analysis layer surfaces that failure to the caller exactly as
14
+ # it does on MySQL when performance_schema is disabled.
15
+ # - "engine" / "table_collation" / "auto_increment" / "fragmented_mb"
16
+ # columns are emitted as NULL or 0 — PostgreSQL has no direct
17
+ # equivalents. The dashboard renders these gracefully.
18
+ # - PostgreSQL "schema" and MySQL "database" are not equivalent;
19
+ # table_sizes filters to the current search_path's first schema
20
+ # (typically "public").
21
+ module Postgresql
22
+ QUERY_STATS_NOISE_FILTERS = <<~SQL
23
+ query NOT ILIKE 'EXPLAIN%'
24
+ AND query NOT ILIKE 'SHOW %'
25
+ AND query NOT ILIKE 'SET %'
26
+ AND query NOT ILIKE '%pg_stat_statements%'
27
+ AND query NOT ILIKE '%pg_catalog%'
28
+ AND query NOT ILIKE '%information_schema%'
29
+ SQL
30
+
31
+ extend self
32
+
33
+ def table_sizes(_connection)
34
+ <<~SQL
35
+ SELECT
36
+ c.relname AS table_name,
37
+ NULL AS engine,
38
+ NULL AS table_collation,
39
+ NULL AS auto_increment,
40
+ s.last_autoanalyze AS update_time,
41
+ ROUND((pg_table_size(c.oid))::numeric / 1024 / 1024, 2) AS data_mb,
42
+ ROUND((pg_indexes_size(c.oid))::numeric / 1024 / 1024, 2) AS index_mb,
43
+ ROUND((pg_total_relation_size(c.oid))::numeric / 1024 / 1024, 2) AS total_mb,
44
+ 0.0 AS fragmented_mb
45
+ FROM pg_class c
46
+ JOIN pg_namespace n ON n.oid = c.relnamespace
47
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
48
+ WHERE c.relkind = 'r'
49
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
50
+ AND n.nspname NOT LIKE 'pg_temp_%'
51
+ AND n.nspname NOT LIKE 'pg_toast_temp_%'
52
+ ORDER BY pg_total_relation_size(c.oid) DESC
53
+ SQL
54
+ end
55
+
56
+ def query_stats(connection, order_clause:, limit:, include_digest:)
57
+ _ = include_digest
58
+ <<~SQL
59
+ SELECT
60
+ queryid::text AS "DIGEST",
61
+ query AS "DIGEST_TEXT",
62
+ calls AS calls,
63
+ ROUND(total_exec_time::numeric, 1) AS total_time_ms,
64
+ ROUND(mean_exec_time::numeric, 1) AS avg_time_ms,
65
+ ROUND(max_exec_time::numeric, 1) AS max_time_ms,
66
+ rows AS rows_examined,
67
+ rows AS rows_sent,
68
+ 0 AS tmp_disk_tables,
69
+ 0 AS sort_rows,
70
+ NULL AS "FIRST_SEEN",
71
+ NULL AS "LAST_SEEN"
72
+ FROM pg_stat_statements s
73
+ JOIN pg_database d ON d.oid = s.dbid
74
+ WHERE d.datname = #{connection.quote(connection.current_database)}
75
+ AND query IS NOT NULL
76
+ AND #{QUERY_STATS_NOISE_FILTERS.strip}
77
+ ORDER BY #{order_clause}
78
+ LIMIT #{limit}
79
+ SQL
80
+ end
81
+
82
+ def query_stats_order_clause(sort)
83
+ case sort
84
+ when "total_time" then "total_exec_time DESC"
85
+ when "avg_time" then "mean_exec_time DESC"
86
+ when "calls" then "calls DESC"
87
+ when "rows_examined" then "rows DESC"
88
+ else "total_exec_time DESC"
89
+ end
90
+ end
91
+
92
+ def stats_snapshot(connection, limit:)
93
+ <<~SQL
94
+ SELECT
95
+ query AS "DIGEST_TEXT",
96
+ calls AS "COUNT_STAR",
97
+ ROUND(total_exec_time::numeric, 1) AS total_time_ms
98
+ FROM pg_stat_statements s
99
+ JOIN pg_database d ON d.oid = s.dbid
100
+ WHERE d.datname = #{connection.quote(connection.current_database)}
101
+ AND query IS NOT NULL
102
+ AND #{QUERY_STATS_NOISE_FILTERS.strip}
103
+ ORDER BY total_exec_time DESC
104
+ LIMIT #{limit}
105
+ SQL
106
+ end
107
+
108
+ def unused_indexes(_connection, min_scans: 0)
109
+ threshold = [min_scans.to_i, 0].max
110
+ <<~SQL
111
+ SELECT
112
+ s.schemaname AS table_schema,
113
+ s.relname AS table_name,
114
+ s.indexrelname AS index_name,
115
+ s.idx_scan AS reads,
116
+ s.idx_tup_read AS writes,
117
+ c.reltuples::bigint AS table_rows,
118
+ pg_relation_size(s.indexrelid)::bigint AS size_bytes
119
+ FROM pg_stat_user_indexes s
120
+ JOIN pg_index i ON i.indexrelid = s.indexrelid
121
+ JOIN pg_class c ON c.oid = s.relid
122
+ WHERE NOT i.indisprimary
123
+ AND NOT i.indisunique
124
+ AND s.idx_scan <= #{threshold}
125
+ ORDER BY pg_relation_size(s.indexrelid) DESC, s.indexrelname ASC
126
+ SQL
127
+ end
128
+
129
+ # Last time pg_stat_database counters (which back pg_stat_user_indexes,
130
+ # pg_stat_user_tables, etc.) were reset for the current database.
131
+ # Surfacing this lets the dashboard distinguish "this index is unused"
132
+ # from "stats were reset five minutes ago and nothing has run yet".
133
+ def stats_reset_at(connection)
134
+ connection.select_value(
135
+ "SELECT stats_reset FROM pg_stat_database " \
136
+ "WHERE datname = #{connection.quote(connection.current_database)}",
137
+ )
138
+ rescue StandardError
139
+ nil
140
+ end
141
+
142
+ def drop_index_sql(table:, index_name:)
143
+ _ = table
144
+ %(DROP INDEX IF EXISTS "#{index_name.to_s.gsub('"', '""')}";)
145
+ end
146
+
147
+ def query_history(connection, digest:)
148
+ quoted_digest = connection.quote(digest)
149
+ quoted_db = connection.quote(connection.current_database)
150
+ <<~SQL
151
+ SELECT query AS "DIGEST_TEXT",
152
+ calls AS calls,
153
+ ROUND(total_exec_time::numeric, 2) AS total_time_ms,
154
+ ROUND(mean_exec_time::numeric, 2) AS avg_time_ms,
155
+ ROUND(max_exec_time::numeric, 2) AS max_time_ms,
156
+ rows AS rows_examined,
157
+ rows AS rows_sent,
158
+ NULL AS "FIRST_SEEN",
159
+ NULL AS "LAST_SEEN"
160
+ FROM pg_stat_statements s
161
+ JOIN pg_database d ON d.oid = s.dbid
162
+ WHERE queryid::text = #{quoted_digest}
163
+ AND d.datname = #{quoted_db}
164
+ LIMIT 1
165
+ SQL
166
+ end
167
+
168
+ def digest_text_lookup(connection, digest:)
169
+ quoted_digest = connection.quote(digest)
170
+ <<~SQL
171
+ SELECT query
172
+ FROM pg_stat_statements
173
+ WHERE queryid::text = #{quoted_digest}
174
+ LIMIT 1
175
+ SQL
176
+ end
177
+
178
+ def digest_column_available?(_connection)
179
+ # pg_stat_statements always exposes queryid; treat as a stable digest.
180
+ true
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Factory for dialect-specific SQL query builders used by the analysis
6
+ # classes. Each builder is a stateless module exposing class methods
7
+ # that return raw SQL strings; the analysis class is responsible for
8
+ # executing them and mapping result rows into output hashes.
9
+ #
10
+ # Builders intentionally output a stable column-name contract so that
11
+ # downstream transformation logic doesn't need to know which dialect
12
+ # produced the rows. See QueryBuilders::Mysql and ::Postgresql.
13
+ module QueryBuilders
14
+ extend self
15
+
16
+ def for(connection)
17
+ case connection.server_version.dialect
18
+ when :postgresql then Postgresql
19
+ else Mysql
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ require "sql_genius/core/query_builders/mysql"
27
+ require "sql_genius/core/query_builders/postgresql"