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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
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 = normalize_placeholders(SqlValidator.normalize_identifier_quotes(sql, @connection).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
+ # Captured digest text from pg_stat_statements has literals replaced with
47
+ # $1, $2, ... bind placeholders, and MySQL's performance_schema digest
48
+ # uses ?. Running EXPLAIN directly on that text fails ("there is no
49
+ # parameter $1" on PG, similar on MySQL). Substituting NULL lets the
50
+ # planner produce a plan — selectivity won't match the real query but
51
+ # the shape (join order, indexes used, scan types) is still useful.
52
+ #
53
+ # On PostgreSQL we only substitute $N — those don't appear in
54
+ # hand-written SQL and the substitution is unambiguous. On MySQL we
55
+ # only substitute ? outside of single-quoted strings to avoid mangling
56
+ # user-typed literals like `WHERE name = '?'`.
57
+ def normalize_placeholders(sql)
58
+ if @connection.server_version.postgresql?
59
+ sql.gsub(/\$\d+/, "NULL")
60
+ else
61
+ replace_unquoted_question_marks(sql)
62
+ end
63
+ end
64
+
65
+ # Walks the SQL once, tracking whether we're inside a single-quoted
66
+ # string (with '' as the escape). Replaces ? with NULL only when
67
+ # outside a string literal.
68
+ def replace_unquoted_question_marks(sql)
69
+ out = +""
70
+ in_string = false
71
+ i = 0
72
+ while i < sql.length
73
+ ch = sql[i]
74
+ # Doubled single quote inside a string literal is an escaped quote;
75
+ # consume both chars without toggling in_string.
76
+ if in_string && ch == "'" && sql[i + 1] == "'"
77
+ out << "''"
78
+ i += 2
79
+ next
80
+ end
81
+ if ch == "'"
82
+ in_string = !in_string
83
+ out << ch
84
+ elsif !in_string && ch == "?"
85
+ out << "NULL"
86
+ else
87
+ out << ch
88
+ end
89
+ i += 1
90
+ end
91
+ out
92
+ end
93
+
94
+ # Heuristic: SQL ends with a value-like token (identifier, number, closing
95
+ # paren/bracket, or closing quote). A trailing SQL keyword such as WHERE,
96
+ # AND, OR, ON, JOIN, SET, HAVING, or a comma/operator means the statement
97
+ # was cut before its next token.
98
+ 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
99
+
100
+ def looks_complete?(sql)
101
+ # Strip Rails-style query annotation comments (/*action=...*/) before
102
+ # inspecting the trailing token. Otherwise the `/` at the end of `*/`
103
+ # would trip the trailing-operator check and false-flag the statement
104
+ # as truncated.
105
+ bare = sql.gsub(%r{/\*.*?\*/}m, " ").strip
106
+ return false if bare.match?(TRAILING_KEYWORD_PATTERN)
107
+ return false if bare.match?(%r{[,=<>!(+\-*/]\s*$})
108
+
109
+ bare.match?(/[\w'"`)\]]\s*$/)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SqlGenius
6
+ module Core
7
+ # Runs SELECT queries against a Core::Connection with SQL validation,
8
+ # row-limit application, dialect-appropriate timeout hints, and column
9
+ # masking. Returns a Core::ExecutionResult on success or raises a
10
+ # specific error class on failure.
11
+ #
12
+ # Timeout strategy by vendor:
13
+ # MySQL — wraps SELECT with /*+ MAX_EXECUTION_TIME(ms) */ hint
14
+ # MariaDB — prefixes SQL with SET STATEMENT max_statement_time=s FOR
15
+ # PostgreSQL — issues SET statement_timeout = ms before the query and
16
+ # resets it to 0 in an ensure block (the server enforces
17
+ # the cancel-on-timeout behaviour)
18
+ #
19
+ # Does NOT handle audit logging — the caller is responsible for
20
+ # recording successful queries and errors using whatever logger it owns.
21
+ class QueryRunner
22
+ class Rejected < Core::Error; end
23
+ class Timeout < Core::Error; end
24
+
25
+ TIMEOUT_PATTERNS = [
26
+ "max_statement_time",
27
+ "max_execution_time",
28
+ "Query execution was interrupted",
29
+ "canceling statement due to statement timeout",
30
+ ].freeze
31
+
32
+ def initialize(connection, config)
33
+ @connection = connection
34
+ @config = config
35
+ end
36
+
37
+ def run(sql, row_limit:)
38
+ validation_error = SqlValidator.validate(
39
+ sql,
40
+ blocked_tables: @config.blocked_tables,
41
+ connection: @connection,
42
+ )
43
+ raise Rejected, validation_error if validation_error
44
+
45
+ normalized = SqlValidator.normalize_identifier_quotes(sql, @connection)
46
+ limited = SqlValidator.apply_row_limit(normalized, row_limit)
47
+
48
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ result = with_timeout do
50
+ @connection.exec_query(apply_timeout_hint(limited))
51
+ end
52
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
53
+
54
+ masked_rows = mask_rows(result)
55
+
56
+ ExecutionResult.new(
57
+ columns: result.columns,
58
+ rows: masked_rows,
59
+ execution_time_ms: duration_ms,
60
+ truncated: masked_rows.length >= row_limit,
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def apply_timeout_hint(sql)
67
+ case vendor
68
+ when :mariadb
69
+ timeout_seconds = @config.query_timeout_ms / 1000
70
+ "SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
71
+ when :postgresql
72
+ # PostgreSQL timeout is set out-of-band in with_timeout; the
73
+ # query itself is sent unchanged.
74
+ sql
75
+ else
76
+ sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{@config.query_timeout_ms}) */")
77
+ end
78
+ end
79
+
80
+ def with_timeout
81
+ if vendor == :postgresql
82
+ @connection.exec_query("SET statement_timeout = #{@config.query_timeout_ms}")
83
+ begin
84
+ yield
85
+ ensure
86
+ begin
87
+ @connection.exec_query("SET statement_timeout = 0")
88
+ rescue StandardError
89
+ # If the session is already torn down we can't restore — that's fine.
90
+ end
91
+ end
92
+ else
93
+ yield
94
+ end
95
+ rescue StandardError => e
96
+ raise Timeout, e.message if timeout_error?(e)
97
+
98
+ raise
99
+ end
100
+
101
+ def vendor
102
+ @connection.server_version.vendor
103
+ end
104
+
105
+ def mask_rows(result)
106
+ mask_indices = result.columns.each_with_index.select do |name, _i|
107
+ SqlValidator.masked_column?(name, @config.masked_column_patterns)
108
+ end.map { |(_name, i)| i }.to_set
109
+
110
+ return result.rows if mask_indices.empty?
111
+
112
+ result.rows.map do |row|
113
+ row.each_with_index.map { |value, i| mask_indices.include?(i) ? "[REDACTED]" : value }
114
+ end
115
+ end
116
+
117
+ def timeout_error?(exception)
118
+ msg = exception.message
119
+ TIMEOUT_PATTERNS.any? { |pattern| msg.include?(pattern) }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ # Identifies the database vendor and version. Adapters construct one
6
+ # from the server's VERSION() output.
7
+ #
8
+ # Three vendors are recognised: :mysql, :mariadb, :postgresql.
9
+ # #dialect collapses these into the SQL family used by query builders
10
+ # — :mysql (covering both MySQL and MariaDB) or :postgresql.
11
+ class ServerInfo
12
+ attr_reader :vendor, :version
13
+
14
+ class << self
15
+ def parse(version_string)
16
+ str = version_string.to_s
17
+ vendor = if str.match?(/postgresql/i)
18
+ :postgresql
19
+ elsif str.downcase.include?("mariadb")
20
+ :mariadb
21
+ else
22
+ :mysql
23
+ end
24
+ new(vendor: vendor, version: str)
25
+ end
26
+ end
27
+
28
+ # vendor must be :mysql, :mariadb, or :postgresql
29
+ def initialize(vendor:, version:)
30
+ @vendor = vendor
31
+ @version = version
32
+ freeze
33
+ end
34
+
35
+ def mariadb?
36
+ @vendor == :mariadb
37
+ end
38
+
39
+ def mysql?
40
+ @vendor == :mysql
41
+ end
42
+
43
+ def postgresql?
44
+ @vendor == :postgresql
45
+ end
46
+
47
+ # SQL family used to pick a query builder. MySQL and MariaDB share
48
+ # one dialect; PostgreSQL is its own.
49
+ def dialect
50
+ postgresql? ? :postgresql : :mysql
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ module Core
5
+ module SqlValidator
6
+ FORBIDDEN_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "GRANT", "REVOKE"].freeze
7
+
8
+ MYSQL_SYSTEM_SCHEMAS = ["information_schema", "mysql", "performance_schema", "sys"].freeze
9
+ POSTGRESQL_SYSTEM_SCHEMAS = ["information_schema", "pg_catalog", "pg_toast", "pg_temp"].freeze
10
+
11
+ extend self
12
+
13
+ def validate(sql, blocked_tables:, connection:)
14
+ return "Please enter a query." if sql.nil? || sql.strip.empty?
15
+
16
+ normalized = sql.gsub(/--.*$/, "").gsub(%r{/\*.*?\*/}m, "").strip
17
+
18
+ unless normalized.match?(/\ASELECT\b/i) || normalized.match?(/\AWITH\b/i)
19
+ return "Only SELECT queries are allowed."
20
+ end
21
+
22
+ system_schemas = system_schemas_for(connection)
23
+ if normalized.match?(/\b(#{system_schemas.join("|")})\b/i)
24
+ return "Access to system schemas is not allowed."
25
+ end
26
+
27
+ FORBIDDEN_KEYWORDS.each do |keyword|
28
+ return "#{keyword} statements are not allowed." if normalized.match?(/\b#{keyword}\b/i)
29
+ end
30
+
31
+ tables_in_query = extract_table_references(normalized, connection)
32
+ blocked = tables_in_query & blocked_tables
33
+ if blocked.any?
34
+ return "Access denied for table(s): #{blocked.join(", ")}."
35
+ end
36
+
37
+ nil
38
+ end
39
+
40
+ def extract_table_references(sql, connection)
41
+ tables = []
42
+ sql.scan(/\bFROM\s+((?:["`]?\w+["`]?(?:\s*,\s*["`]?\w+["`]?)*)+)/i) do |m|
43
+ m[0].scan(/["`]?(\w+)["`]?/) { |t| tables << t[0] }
44
+ end
45
+ sql.scan(/\bJOIN\s+["`]?(\w+)["`]?/i) { |m| tables << m[0] }
46
+ sql.scan(/\b(?:INTO|UPDATE)\s+["`]?(\w+)["`]?/i) { |m| tables << m[0] }
47
+ tables.uniq.map(&:downcase) & connection.tables
48
+ end
49
+
50
+ def apply_row_limit(sql, limit)
51
+ if sql.match?(/\bLIMIT\s+\d+\s*,\s*\d+/i)
52
+ sql.gsub(/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i) do
53
+ "LIMIT #{::Regexp.last_match(1).to_i}, #{[::Regexp.last_match(2).to_i, limit].min}"
54
+ end
55
+ elsif sql.match?(/\bLIMIT\s+\d+/i)
56
+ sql.gsub(/\bLIMIT\s+(\d+)/i) { "LIMIT #{[::Regexp.last_match(1).to_i, limit].min}" }
57
+ else
58
+ "#{sql.gsub(/;\s*\z/, "")} LIMIT #{limit}"
59
+ end
60
+ end
61
+
62
+ def normalize_identifier_quotes(sql, connection)
63
+ quote = connection.quote_table_name("sql_genius_identifier_probe")[0]
64
+ return sql if quote == "`" || !sql.include?("`")
65
+
66
+ rewrite_backtick_identifiers(sql, connection)
67
+ end
68
+
69
+ def masked_column?(column_name, patterns)
70
+ patterns.any? { |pattern| column_name.downcase.include?(pattern) }
71
+ end
72
+
73
+ def system_schemas_for(connection)
74
+ return MYSQL_SYSTEM_SCHEMAS unless connection.respond_to?(:server_version)
75
+
76
+ connection.server_version.postgresql? ? POSTGRESQL_SYSTEM_SCHEMAS : MYSQL_SYSTEM_SCHEMAS
77
+ rescue StandardError
78
+ MYSQL_SYSTEM_SCHEMAS
79
+ end
80
+
81
+ def rewrite_backtick_identifiers(sql, connection)
82
+ output = +""
83
+ i = 0
84
+
85
+ while i < sql.length
86
+ char = sql[i]
87
+
88
+ if char == "'"
89
+ literal, i = read_single_quoted_literal(sql, i)
90
+ output << literal
91
+ elsif char == "`"
92
+ identifier, i = read_backtick_identifier(sql, i)
93
+ output << connection.quote_table_name(identifier)
94
+ else
95
+ output << char
96
+ i += 1
97
+ end
98
+ end
99
+
100
+ output
101
+ end
102
+
103
+ def read_single_quoted_literal(sql, index)
104
+ output = +"'"
105
+ i = index + 1
106
+
107
+ while i < sql.length
108
+ output << sql[i]
109
+ if sql[i] == "'"
110
+ if sql[i + 1] == "'"
111
+ output << sql[i + 1]
112
+ i += 2
113
+ next
114
+ end
115
+
116
+ i += 1
117
+ break
118
+ end
119
+ i += 1
120
+ end
121
+
122
+ [output, i]
123
+ end
124
+
125
+ def read_backtick_identifier(sql, index)
126
+ output = +""
127
+ i = index + 1
128
+
129
+ while i < sql.length
130
+ if sql[i] == "`"
131
+ if sql[i + 1] == "`"
132
+ output << "`"
133
+ i += 2
134
+ next
135
+ end
136
+
137
+ i += 1
138
+ break
139
+ end
140
+
141
+ output << sql[i]
142
+ i += 1
143
+ end
144
+
145
+ [output, i]
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,59 @@
1
+ <!-- Explain Results -->
2
+ <div id="explain-results" class="mg-mt mg-hidden">
3
+ <div class="mg-card">
4
+ <div class="mg-card-header">
5
+ <span><strong>&#128270; EXPLAIN Output</strong></span>
6
+ <div>
7
+ <% if @ai_enabled %>
8
+ <button id="explain-optimize" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; AI Optimization</button>
9
+ <button id="explain-index-advisor" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Index Advisor</button>
10
+ <% end %>
11
+ <button id="explain-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005; Close</button>
12
+ </div>
13
+ </div>
14
+ <div class="mg-card-body">
15
+ <div class="mg-table-wrap">
16
+ <table class="mg-table">
17
+ <thead id="explain-thead"></thead>
18
+ <tbody id="explain-tbody"></tbody>
19
+ </table>
20
+ </div>
21
+ <div id="optimize-results" class="mg-hidden mg-mt">
22
+ <div id="optimize-content" class="mg-alert mg-alert-info"></div>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Results Area -->
29
+ <div id="query-results" class="mg-mt mg-hidden">
30
+ <div style="display:flex;justify-content:space-between;align-items:center;">
31
+ <div id="results-stats" class="mg-mb mg-hidden">
32
+ <span id="results-row-count" class="mg-badge mg-badge-info"></span>
33
+ <span id="results-time" class="mg-badge mg-badge-secondary"></span>
34
+ <span id="results-truncated" class="mg-badge mg-badge-warning mg-hidden">Results truncated</span>
35
+ </div>
36
+ <button id="results-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm" style="margin-bottom:12px;">&#10005; Dismiss</button>
37
+ </div>
38
+ <div id="results-alert" class="mg-hidden"></div>
39
+ <div id="results-table-wrapper" class="mg-table-wrap mg-hidden">
40
+ <table class="mg-table">
41
+ <thead id="results-thead"></thead>
42
+ <tbody id="results-tbody"></tbody>
43
+ </table>
44
+ </div>
45
+ <div id="results-empty" class="mg-text-center mg-text-muted mg-hidden">No rows returned.</div>
46
+ </div>
47
+
48
+ <!-- AI Query Analysis Results -->
49
+ <div id="ai-query-result" class="mg-mt mg-hidden">
50
+ <div class="mg-card">
51
+ <div class="mg-card-header">
52
+ <span id="ai-query-title"><strong>&#9889; AI Analysis</strong></span>
53
+ <button id="ai-query-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005;</button>
54
+ </div>
55
+ <div class="mg-card-body">
56
+ <div id="ai-query-content" style="font-size:13px;"></div>
57
+ </div>
58
+ </div>
59
+ </div>
@@ -0,0 +1,43 @@
1
+ <!-- AI Tools Tab -->
2
+ <div class="mg-tab-content" id="tab-aitools">
3
+ <!-- Schema Review -->
4
+ <div class="mg-card mg-mb">
5
+ <div class="mg-card-header"><strong>&#9889; Schema Review</strong></div>
6
+ <div class="mg-card-body">
7
+ <div class="mg-text-muted mg-mb" style="font-size:12px;">Analyze your schema for anti-patterns: inappropriate column types, missing indexes, naming inconsistencies, and more.</div>
8
+ <div class="mg-row" style="align-items:flex-end;">
9
+ <div class="mg-col-4 mg-field">
10
+ <label for="schema-table">Table (leave blank for all)</label>
11
+ <select id="schema-table">
12
+ <option value="">All tables (top 20)</option>
13
+ <% @all_tables.each do |table| %>
14
+ <option value="<%= table %>"><%= table %></option>
15
+ <% end %>
16
+ </select>
17
+ </div>
18
+ <div class="mg-field">
19
+ <button id="schema-review-btn" class="mg-btn mg-btn-primary mg-btn-sm">&#9889; Analyze Schema</button>
20
+ </div>
21
+ </div>
22
+ <div id="schema-result" class="mg-mt mg-hidden">
23
+ <div id="schema-result-content" style="font-size:13px;"></div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Migration Risk Assessment -->
29
+ <div class="mg-card">
30
+ <div class="mg-card-header"><strong>&#9889; Migration Risk Assessment</strong></div>
31
+ <div class="mg-card-body">
32
+ <div class="mg-text-muted mg-mb" style="font-size:12px;">Paste a Rails migration or DDL and get a risk assessment: lock duration, impact on active queries, deployment strategy.</div>
33
+ <div class="mg-field">
34
+ <textarea id="migration-input" rows="8" placeholder="class AddIndexToUsers < ActiveRecord::Migration[7.0]&#10; def change&#10; add_index :users, :email, unique: true&#10; end&#10;end"></textarea>
35
+ </div>
36
+ <button id="migration-assess-btn" class="mg-btn mg-btn-primary mg-btn-sm">&#9889; Assess Risk</button>
37
+ <div id="migration-result" class="mg-mt mg-hidden">
38
+ <div id="migration-risk-badge" style="margin-bottom:8px;"></div>
39
+ <div id="migration-result-content" style="font-size:13px;"></div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>