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,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"
|