mysql_genius-core 0.4.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 +21 -0
- data/lib/mysql_genius/core/ai/client.rb +99 -0
- data/lib/mysql_genius/core/ai/config.rb +43 -0
- data/lib/mysql_genius/core/ai/optimization.rb +81 -0
- data/lib/mysql_genius/core/ai/suggestion.rb +76 -0
- data/lib/mysql_genius/core/analysis/duplicate_indexes.rb +83 -0
- data/lib/mysql_genius/core/analysis/query_stats.rb +103 -0
- data/lib/mysql_genius/core/analysis/server_overview.rb +124 -0
- data/lib/mysql_genius/core/analysis/table_sizes.rb +66 -0
- data/lib/mysql_genius/core/analysis/unused_indexes.rb +57 -0
- data/lib/mysql_genius/core/column_definition.rb +30 -0
- data/lib/mysql_genius/core/connection/fake_adapter.rb +110 -0
- data/lib/mysql_genius/core/connection.rb +36 -0
- data/lib/mysql_genius/core/execution_result.rb +27 -0
- data/lib/mysql_genius/core/index_definition.rb +23 -0
- data/lib/mysql_genius/core/query_explainer.rb +60 -0
- data/lib/mysql_genius/core/query_runner/config.rb +21 -0
- data/lib/mysql_genius/core/query_runner.rb +94 -0
- data/lib/mysql_genius/core/result.rb +43 -0
- data/lib/mysql_genius/core/server_info.rb +33 -0
- data/lib/mysql_genius/core/sql_validator.rb +59 -0
- data/lib/mysql_genius/core/version.rb +7 -0
- data/lib/mysql_genius/core.rb +35 -0
- data/mysql_genius-core.gemspec +34 -0
- metadata +73 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Queries information_schema.tables for data/index/fragmentation metrics
|
|
7
|
+
# per BASE TABLE in the current database, plus an exact SELECT COUNT(*)
|
|
8
|
+
# for each table. Returns an array of hashes suitable for JSON rendering.
|
|
9
|
+
#
|
|
10
|
+
# Takes a Core::Connection. No configuration required — the current
|
|
11
|
+
# database is read from connection.current_database.
|
|
12
|
+
class TableSizes
|
|
13
|
+
def initialize(connection)
|
|
14
|
+
@connection = connection
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
db_name = @connection.current_database
|
|
19
|
+
|
|
20
|
+
result = @connection.exec_query(<<~SQL)
|
|
21
|
+
SELECT
|
|
22
|
+
table_name,
|
|
23
|
+
engine,
|
|
24
|
+
table_collation,
|
|
25
|
+
auto_increment,
|
|
26
|
+
update_time,
|
|
27
|
+
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
|
|
28
|
+
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
|
|
29
|
+
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
|
|
30
|
+
ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
|
|
31
|
+
FROM information_schema.tables
|
|
32
|
+
WHERE table_schema = #{@connection.quote(db_name)}
|
|
33
|
+
AND table_type = 'BASE TABLE'
|
|
34
|
+
ORDER BY (data_length + index_length) DESC
|
|
35
|
+
SQL
|
|
36
|
+
|
|
37
|
+
result.to_hashes.map do |row|
|
|
38
|
+
table_name = row["table_name"] || row["TABLE_NAME"]
|
|
39
|
+
row_count = begin
|
|
40
|
+
@connection.select_value("SELECT COUNT(*) FROM #{@connection.quote_table_name(table_name)}")
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
total_mb = (row["total_mb"] || 0).to_f
|
|
46
|
+
fragmented_mb = (row["fragmented_mb"] || 0).to_f
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
table: table_name,
|
|
50
|
+
rows: row_count,
|
|
51
|
+
engine: row["engine"] || row["ENGINE"],
|
|
52
|
+
collation: row["table_collation"] || row["TABLE_COLLATION"],
|
|
53
|
+
auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
|
|
54
|
+
updated_at: row["update_time"] || row["UPDATE_TIME"],
|
|
55
|
+
data_mb: (row["data_mb"] || 0).to_f,
|
|
56
|
+
index_mb: (row["index_mb"] || 0).to_f,
|
|
57
|
+
total_mb: total_mb,
|
|
58
|
+
fragmented_mb: fragmented_mb,
|
|
59
|
+
needs_optimize: total_mb.positive? && fragmented_mb > (total_mb * 0.1),
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module Analysis
|
|
6
|
+
# Queries performance_schema.table_io_waits_summary_by_index_usage
|
|
7
|
+
# joined with information_schema.tables to find indexes with zero
|
|
8
|
+
# reads but non-zero row counts in their parent table. Returns hashes
|
|
9
|
+
# with a ready-to-run DROP INDEX statement per result.
|
|
10
|
+
#
|
|
11
|
+
# Skips the PRIMARY index (should never be dropped) and anonymous
|
|
12
|
+
# rows (where INDEX_NAME IS NULL). Raises if performance_schema is
|
|
13
|
+
# unavailable.
|
|
14
|
+
class UnusedIndexes
|
|
15
|
+
def initialize(connection)
|
|
16
|
+
@connection = connection
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
db_name = @connection.current_database
|
|
21
|
+
|
|
22
|
+
result = @connection.exec_query(<<~SQL)
|
|
23
|
+
SELECT
|
|
24
|
+
s.OBJECT_SCHEMA AS table_schema,
|
|
25
|
+
s.OBJECT_NAME AS table_name,
|
|
26
|
+
s.INDEX_NAME AS index_name,
|
|
27
|
+
s.COUNT_READ AS `reads`,
|
|
28
|
+
s.COUNT_WRITE AS `writes`,
|
|
29
|
+
t.TABLE_ROWS AS table_rows
|
|
30
|
+
FROM performance_schema.table_io_waits_summary_by_index_usage s
|
|
31
|
+
JOIN information_schema.tables t
|
|
32
|
+
ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
|
|
33
|
+
WHERE s.OBJECT_SCHEMA = #{@connection.quote(db_name)}
|
|
34
|
+
AND s.INDEX_NAME IS NOT NULL
|
|
35
|
+
AND s.INDEX_NAME != 'PRIMARY'
|
|
36
|
+
AND s.COUNT_READ = 0
|
|
37
|
+
AND t.TABLE_ROWS > 0
|
|
38
|
+
ORDER BY s.COUNT_WRITE DESC
|
|
39
|
+
SQL
|
|
40
|
+
|
|
41
|
+
result.to_hashes.map do |row|
|
|
42
|
+
table = row["table_name"] || row["TABLE_NAME"]
|
|
43
|
+
index_name = row["index_name"] || row["INDEX_NAME"]
|
|
44
|
+
{
|
|
45
|
+
table: table,
|
|
46
|
+
index_name: index_name,
|
|
47
|
+
reads: (row["reads"] || row["READS"] || 0).to_i,
|
|
48
|
+
writes: (row["writes"] || row["WRITES"] || 0).to_i,
|
|
49
|
+
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
|
|
50
|
+
drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;",
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
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/mysql_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
|
+
"`#{name}`"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attr_reader :tables
|
|
91
|
+
|
|
92
|
+
def columns_for(table)
|
|
93
|
+
@columns_for.fetch(table, [])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def indexes_for(table)
|
|
97
|
+
@indexes_for.fetch(table, [])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def primary_key(table)
|
|
101
|
+
@primary_keys[table]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def close
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
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
|
+
# MysqlGenius::Core::Connection::FakeAdapter — in this gem, for tests
|
|
13
|
+
# MysqlGenius::Core::Connection::ActiveRecordAdapter — in mysql_genius (Rails adapter)
|
|
14
|
+
# MysqlGenius::Core::Connection::TrilogyAdapter — in mysql_genius-desktop (Phase 2)
|
|
15
|
+
#
|
|
16
|
+
# Contract (every adapter must implement):
|
|
17
|
+
#
|
|
18
|
+
# #exec_query(sql) -> Core::Result
|
|
19
|
+
# #select_value(sql) -> Object (first column of first row, or nil)
|
|
20
|
+
# #server_version -> Core::ServerInfo
|
|
21
|
+
# #current_database -> String
|
|
22
|
+
# #quote(value) -> String (SQL-escaped value)
|
|
23
|
+
# #quote_table_name(name) -> String (backtick-quoted identifier)
|
|
24
|
+
# #tables -> Array<String>
|
|
25
|
+
# #columns_for(table) -> Array<Core::ColumnDefinition>
|
|
26
|
+
# #indexes_for(table) -> Array<Core::IndexDefinition>
|
|
27
|
+
# #primary_key(table) -> String or nil
|
|
28
|
+
# #close -> nil
|
|
29
|
+
#
|
|
30
|
+
# Adapters may implement additional methods for efficiency, but any
|
|
31
|
+
# core code that depends on the connection must only call methods
|
|
32
|
+
# defined in this contract.
|
|
33
|
+
module Connection
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
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 MysqlGenius
|
|
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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Runs EXPLAIN against a SELECT query via a Core::Connection. Optionally
|
|
6
|
+
# skips SQL validation (used for explaining captured slow queries from
|
|
7
|
+
# mysql's own logs where the exact text may include references to
|
|
8
|
+
# otherwise-blocked tables).
|
|
9
|
+
#
|
|
10
|
+
# Rejects obviously-truncated SQL — captured slow queries from the
|
|
11
|
+
# slow query log are capped at ~2000 characters, so if the last
|
|
12
|
+
# character doesn't look like a valid terminator we refuse to try.
|
|
13
|
+
# This avoids confusing EXPLAIN errors from partial statements.
|
|
14
|
+
#
|
|
15
|
+
# Reuses Core::QueryRunner::Rejected for validation failures so
|
|
16
|
+
# callers can rescue one error type for both runners.
|
|
17
|
+
class QueryExplainer
|
|
18
|
+
class Truncated < Core::Error; end
|
|
19
|
+
|
|
20
|
+
def initialize(connection, config)
|
|
21
|
+
@connection = connection
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def explain(sql, skip_validation: false)
|
|
26
|
+
unless skip_validation
|
|
27
|
+
error = SqlValidator.validate(
|
|
28
|
+
sql,
|
|
29
|
+
blocked_tables: @config.blocked_tables,
|
|
30
|
+
connection: @connection,
|
|
31
|
+
)
|
|
32
|
+
raise QueryRunner::Rejected, error if error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
clean_sql = sql.gsub(/;\s*\z/, "")
|
|
36
|
+
|
|
37
|
+
unless looks_complete?(clean_sql)
|
|
38
|
+
raise Truncated, "This query appears to be truncated and cannot be explained."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@connection.exec_query("EXPLAIN #{clean_sql}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Heuristic: SQL ends with a value-like token (identifier, number, closing
|
|
47
|
+
# paren/bracket, or closing quote). A trailing SQL keyword such as WHERE,
|
|
48
|
+
# AND, OR, ON, JOIN, SET, HAVING, or a comma/operator means the statement
|
|
49
|
+
# was cut before its next token.
|
|
50
|
+
TRAILING_KEYWORD_PATTERN = /\b(WHERE|AND|OR|ON|JOIN|INNER|OUTER|LEFT|RIGHT|CROSS|HAVING|SET|BETWEEN|LIKE|IN|NOT|IS|FROM|SELECT|GROUP|ORDER|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT)\s*$/i
|
|
51
|
+
|
|
52
|
+
def looks_complete?(sql)
|
|
53
|
+
return false if sql.match?(TRAILING_KEYWORD_PATTERN)
|
|
54
|
+
return false if sql.match?(%r{[,=<>!(+\-*/]\s*$})
|
|
55
|
+
|
|
56
|
+
sql.match?(/[\w'"`)\]]\s*$/)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Forward declaration so Config can be namespaced under QueryRunner.
|
|
6
|
+
# The full QueryRunner class is defined in query_runner.rb.
|
|
7
|
+
class QueryRunner
|
|
8
|
+
Config = Struct.new(
|
|
9
|
+
:blocked_tables,
|
|
10
|
+
:masked_column_patterns,
|
|
11
|
+
:query_timeout_ms,
|
|
12
|
+
keyword_init: true,
|
|
13
|
+
) do
|
|
14
|
+
def initialize(*)
|
|
15
|
+
super
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module MysqlGenius
|
|
6
|
+
module Core
|
|
7
|
+
# Runs SELECT queries against a Core::Connection with SQL validation,
|
|
8
|
+
# row-limit application, timeout hints (MySQL or MariaDB flavor), and
|
|
9
|
+
# column masking. Returns a Core::ExecutionResult on success or raises
|
|
10
|
+
# a specific error class on failure.
|
|
11
|
+
#
|
|
12
|
+
# Does NOT handle audit logging — the caller (Rails concern or future
|
|
13
|
+
# desktop sidecar) is responsible for recording successful queries and
|
|
14
|
+
# errors using whatever logger it owns.
|
|
15
|
+
class QueryRunner
|
|
16
|
+
class Rejected < Core::Error; end
|
|
17
|
+
class Timeout < Core::Error; end
|
|
18
|
+
|
|
19
|
+
TIMEOUT_PATTERNS = [
|
|
20
|
+
"max_statement_time",
|
|
21
|
+
"max_execution_time",
|
|
22
|
+
"Query execution was interrupted",
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(connection, config)
|
|
26
|
+
@connection = connection
|
|
27
|
+
@config = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(sql, row_limit:)
|
|
31
|
+
validation_error = SqlValidator.validate(
|
|
32
|
+
sql,
|
|
33
|
+
blocked_tables: @config.blocked_tables,
|
|
34
|
+
connection: @connection,
|
|
35
|
+
)
|
|
36
|
+
raise Rejected, validation_error if validation_error
|
|
37
|
+
|
|
38
|
+
limited = SqlValidator.apply_row_limit(sql, row_limit)
|
|
39
|
+
timed = apply_timeout_hint(limited)
|
|
40
|
+
|
|
41
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
result = begin
|
|
43
|
+
@connection.exec_query(timed)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
raise Timeout, e.message if timeout_error?(e)
|
|
46
|
+
|
|
47
|
+
raise
|
|
48
|
+
end
|
|
49
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
|
|
50
|
+
|
|
51
|
+
masked_rows = mask_rows(result)
|
|
52
|
+
|
|
53
|
+
ExecutionResult.new(
|
|
54
|
+
columns: result.columns,
|
|
55
|
+
rows: masked_rows,
|
|
56
|
+
execution_time_ms: duration_ms,
|
|
57
|
+
truncated: masked_rows.length >= row_limit,
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def apply_timeout_hint(sql)
|
|
64
|
+
if mariadb?
|
|
65
|
+
timeout_seconds = @config.query_timeout_ms / 1000
|
|
66
|
+
"SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
|
|
67
|
+
else
|
|
68
|
+
sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{@config.query_timeout_ms}) */")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mariadb?
|
|
73
|
+
@connection.server_version.mariadb?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mask_rows(result)
|
|
77
|
+
mask_indices = result.columns.each_with_index.select do |name, _i|
|
|
78
|
+
SqlValidator.masked_column?(name, @config.masked_column_patterns)
|
|
79
|
+
end.map { |(_name, i)| i }.to_set
|
|
80
|
+
|
|
81
|
+
return result.rows if mask_indices.empty?
|
|
82
|
+
|
|
83
|
+
result.rows.map do |row|
|
|
84
|
+
row.each_with_index.map { |value, i| mask_indices.include?(i) ? "[REDACTED]" : value }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def timeout_error?(exception)
|
|
89
|
+
msg = exception.message
|
|
90
|
+
TIMEOUT_PATTERNS.any? { |pattern| msg.include?(pattern) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable value object representing the result of a query.
|
|
6
|
+
# Adapters translate their native result types into this shape.
|
|
7
|
+
class Result
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
attr_reader :columns, :rows
|
|
11
|
+
|
|
12
|
+
def initialize(columns:, rows:)
|
|
13
|
+
@columns = columns.freeze
|
|
14
|
+
@rows = rows.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&block)
|
|
19
|
+
return @rows.each unless block
|
|
20
|
+
|
|
21
|
+
@rows.each(&block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_a
|
|
25
|
+
@rows.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def count
|
|
29
|
+
@rows.length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def empty?
|
|
33
|
+
@rows.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns rows as an array of hashes keyed by column name. Mirrors
|
|
37
|
+
# ActiveRecord::Result#to_a's hashification behavior.
|
|
38
|
+
def to_hashes
|
|
39
|
+
@rows.map { |row| @columns.zip(row).to_h }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
# Identifies the database vendor and version. Adapters construct one
|
|
6
|
+
# from the server's VERSION() output.
|
|
7
|
+
class ServerInfo
|
|
8
|
+
attr_reader :vendor, :version
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def parse(version_string)
|
|
12
|
+
vendor = version_string.to_s.downcase.include?("mariadb") ? :mariadb : :mysql
|
|
13
|
+
new(vendor: vendor, version: version_string)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# vendor must be :mysql or :mariadb
|
|
18
|
+
def initialize(vendor:, version:)
|
|
19
|
+
@vendor = vendor
|
|
20
|
+
@version = version
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mariadb?
|
|
25
|
+
@vendor == :mariadb
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mysql?
|
|
29
|
+
@vendor == :mysql
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MysqlGenius
|
|
4
|
+
module Core
|
|
5
|
+
module SqlValidator
|
|
6
|
+
FORBIDDEN_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "GRANT", "REVOKE"].freeze
|
|
7
|
+
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
def validate(sql, blocked_tables:, connection:)
|
|
11
|
+
return "Please enter a query." if sql.nil? || sql.strip.empty?
|
|
12
|
+
|
|
13
|
+
normalized = sql.gsub(/--.*$/, "").gsub(%r{/\*.*?\*/}m, "").strip
|
|
14
|
+
|
|
15
|
+
unless normalized.match?(/\ASELECT\b/i) || normalized.match?(/\AWITH\b/i)
|
|
16
|
+
return "Only SELECT queries are allowed."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return "Access to system schemas is not allowed." if normalized.match?(/\b(information_schema|mysql|performance_schema|sys)\b/i)
|
|
20
|
+
|
|
21
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
|
22
|
+
return "#{keyword} statements are not allowed." if normalized.match?(/\b#{keyword}\b/i)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
tables_in_query = extract_table_references(normalized, connection)
|
|
26
|
+
blocked = tables_in_query & blocked_tables
|
|
27
|
+
if blocked.any?
|
|
28
|
+
return "Access denied for table(s): #{blocked.join(", ")}."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def extract_table_references(sql, connection)
|
|
35
|
+
tables = []
|
|
36
|
+
sql.scan(/\bFROM\s+((?:`?\w+`?(?:\s*,\s*`?\w+`?)*)+)/i) { |m| m[0].scan(/`?(\w+)`?/) { |t| tables << t[0] } }
|
|
37
|
+
sql.scan(/\bJOIN\s+`?(\w+)`?/i) { |m| tables << m[0] }
|
|
38
|
+
sql.scan(/\b(?:INTO|UPDATE)\s+`?(\w+)`?/i) { |m| tables << m[0] }
|
|
39
|
+
tables.uniq.map(&:downcase) & connection.tables
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_row_limit(sql, limit)
|
|
43
|
+
if sql.match?(/\bLIMIT\s+\d+\s*,\s*\d+/i)
|
|
44
|
+
sql.gsub(/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i) do
|
|
45
|
+
"LIMIT #{::Regexp.last_match(1).to_i}, #{[::Regexp.last_match(2).to_i, limit].min}"
|
|
46
|
+
end
|
|
47
|
+
elsif sql.match?(/\bLIMIT\s+\d+/i)
|
|
48
|
+
sql.gsub(/\bLIMIT\s+(\d+)/i) { "LIMIT #{[::Regexp.last_match(1).to_i, limit].min}" }
|
|
49
|
+
else
|
|
50
|
+
"#{sql.gsub(/;\s*\z/, "")} LIMIT #{limit}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def masked_column?(column_name, patterns)
|
|
55
|
+
patterns.any? { |pattern| column_name.downcase.include?(pattern) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|