query_guard 0.4.2 → 0.5.1
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 +4 -4
- data/CHANGELOG.md +89 -1
- data/DESIGN.md +420 -0
- data/INDEX.md +309 -0
- data/README.md +579 -30
- data/exe/queryguard +23 -0
- data/lib/query_guard/action_controller_subscriber.rb +27 -0
- data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
- data/lib/query_guard/analysis/risk_detectors.rb +258 -0
- data/lib/query_guard/analysis/risk_level.rb +35 -0
- data/lib/query_guard/analyzers/base.rb +30 -0
- data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
- data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
- data/lib/query_guard/analyzers/registry.rb +57 -0
- data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
- data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
- data/lib/query_guard/budget.rb +148 -0
- data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
- data/lib/query_guard/cli/command.rb +93 -0
- data/lib/query_guard/cli/commands/analyze.rb +52 -0
- data/lib/query_guard/cli/commands/check.rb +58 -0
- data/lib/query_guard/cli/formatter.rb +278 -0
- data/lib/query_guard/cli/json_reporter.rb +247 -0
- data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
- data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
- data/lib/query_guard/cli.rb +197 -0
- data/lib/query_guard/client.rb +4 -6
- data/lib/query_guard/config.rb +145 -6
- data/lib/query_guard/core/context.rb +80 -0
- data/lib/query_guard/core/finding.rb +162 -0
- data/lib/query_guard/core/finding_builders.rb +152 -0
- data/lib/query_guard/core/query.rb +40 -0
- data/lib/query_guard/explain/adapter_interface.rb +89 -0
- data/lib/query_guard/explain/explain_enricher.rb +367 -0
- data/lib/query_guard/explain/plan_signals.rb +385 -0
- data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
- data/lib/query_guard/exporter.rb +124 -0
- data/lib/query_guard/fingerprint.rb +96 -0
- data/lib/query_guard/middleware.rb +101 -15
- data/lib/query_guard/migrations/database_adapter.rb +88 -0
- data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
- data/lib/query_guard/migrations/migration_risk_detectors.rb +390 -0
- data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
- data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
- data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
- data/lib/query_guard/publish.rb +38 -0
- data/lib/query_guard/rspec.rb +119 -0
- data/lib/query_guard/security.rb +99 -0
- data/lib/query_guard/store.rb +38 -0
- data/lib/query_guard/subscriber.rb +46 -15
- data/lib/query_guard/suggest/index_suggester.rb +176 -0
- data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
- data/lib/query_guard/trace.rb +106 -0
- data/lib/query_guard/uploader/http_uploader.rb +166 -0
- data/lib/query_guard/uploader/interface.rb +79 -0
- data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
- data/lib/query_guard/uploader/registry.rb +37 -0
- data/lib/query_guard/uploader/upload_service.rb +80 -0
- data/lib/query_guard/version.rb +1 -1
- data/lib/query_guard.rb +54 -7
- metadata +78 -10
- data/.rspec +0 -3
- data/Rakefile +0 -21
- data/config/initializers/query_guard.rb +0 -9
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Core
|
|
5
|
+
# Holds the state of a single request or analysis session.
|
|
6
|
+
# Replaces implicit Thread.current usage for better testability and clarity.
|
|
7
|
+
class Context
|
|
8
|
+
attr_reader :queries, :findings
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@queries = []
|
|
12
|
+
@findings = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add a captured query to the context
|
|
16
|
+
def add_query(sql:, duration_ms:, name: nil, started_at: nil, finished_at: nil)
|
|
17
|
+
query = Query.new(
|
|
18
|
+
sql: sql,
|
|
19
|
+
duration_ms: duration_ms,
|
|
20
|
+
name: name,
|
|
21
|
+
started_at: started_at,
|
|
22
|
+
finished_at: finished_at
|
|
23
|
+
)
|
|
24
|
+
@queries << query
|
|
25
|
+
query
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Add a finding to the context
|
|
29
|
+
def add_finding(finding)
|
|
30
|
+
raise ArgumentError, "Must be a Finding" unless finding.is_a?(Finding)
|
|
31
|
+
@findings << finding
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convenience: create and add a finding
|
|
35
|
+
def create_finding(analyzer_name:, rule_name:, severity: :warn, message:, metadata: {}, query: nil)
|
|
36
|
+
finding = Finding.new(
|
|
37
|
+
analyzer_name: analyzer_name,
|
|
38
|
+
rule_name: rule_name,
|
|
39
|
+
severity: severity,
|
|
40
|
+
message: message,
|
|
41
|
+
metadata: metadata,
|
|
42
|
+
query: query
|
|
43
|
+
)
|
|
44
|
+
add_finding(finding)
|
|
45
|
+
finding
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Query counts for easy checking
|
|
49
|
+
def query_count
|
|
50
|
+
@queries.length
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def total_duration_ms
|
|
54
|
+
@queries.sum { |q| q.duration_ms }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Finding counts
|
|
58
|
+
def finding_count
|
|
59
|
+
@findings.length
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def findings_by_severity(severity)
|
|
63
|
+
@findings.select { |f| f.severity == severity.to_sym }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
@queries.clear
|
|
68
|
+
@findings.clear
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
query_count: query_count,
|
|
74
|
+
total_duration_ms: total_duration_ms,
|
|
75
|
+
findings: findings.map(&:to_h)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable result of a rule analysis.
|
|
6
|
+
# Represents a single violation or finding detected by an analyzer.
|
|
7
|
+
#
|
|
8
|
+
# A Finding encapsulates all information about a detected issue:
|
|
9
|
+
# - What rule triggered (analyzer_name, rule_name)
|
|
10
|
+
# - How severe it is (severity)
|
|
11
|
+
# - Where it occurred (file_path, line_number, sql)
|
|
12
|
+
# - Why it matters (title, description, recommendations)
|
|
13
|
+
# - Context data (metadata)
|
|
14
|
+
class Finding
|
|
15
|
+
SEVERITIES = %i[info warn error].freeze
|
|
16
|
+
|
|
17
|
+
# Core identification
|
|
18
|
+
attr_reader :analyzer_name, :rule_name, :severity
|
|
19
|
+
|
|
20
|
+
# Message and description (user-facing)
|
|
21
|
+
attr_reader :title, :description, :message, :recommendations
|
|
22
|
+
|
|
23
|
+
# Location information
|
|
24
|
+
attr_reader :file_path, :line_number, :sql
|
|
25
|
+
|
|
26
|
+
# Additional context
|
|
27
|
+
attr_reader :metadata, :query, :created_at
|
|
28
|
+
|
|
29
|
+
# Internal ID for tracking/deduplication
|
|
30
|
+
attr_reader :id
|
|
31
|
+
|
|
32
|
+
def initialize(
|
|
33
|
+
analyzer_name:,
|
|
34
|
+
rule_name:,
|
|
35
|
+
severity: :warn,
|
|
36
|
+
title: nil,
|
|
37
|
+
description: nil,
|
|
38
|
+
message: nil,
|
|
39
|
+
file_path: nil,
|
|
40
|
+
line_number: nil,
|
|
41
|
+
sql: nil,
|
|
42
|
+
metadata: {},
|
|
43
|
+
query: nil,
|
|
44
|
+
recommendations: []
|
|
45
|
+
)
|
|
46
|
+
@analyzer_name = analyzer_name.to_sym
|
|
47
|
+
@rule_name = rule_name.to_sym
|
|
48
|
+
@severity = validate_severity(severity)
|
|
49
|
+
|
|
50
|
+
# Message handling: use title/description or fall back to message
|
|
51
|
+
@title = title.to_s.freeze if title
|
|
52
|
+
@description = description.to_s.freeze if description
|
|
53
|
+
@message = (message || @title || "").to_s.freeze
|
|
54
|
+
|
|
55
|
+
# Location info
|
|
56
|
+
@file_path = file_path.to_s.freeze if file_path
|
|
57
|
+
@line_number = line_number.to_i if line_number
|
|
58
|
+
@sql = sql.to_s.freeze if sql
|
|
59
|
+
|
|
60
|
+
# Recommendations
|
|
61
|
+
@recommendations = Array(recommendations).map { |r| r.to_s.freeze }.freeze
|
|
62
|
+
|
|
63
|
+
# Metadata
|
|
64
|
+
@metadata = metadata.freeze
|
|
65
|
+
@query = query
|
|
66
|
+
@created_at = Time.now.freeze
|
|
67
|
+
|
|
68
|
+
# Generate deterministic ID based on analyzer, rule, and sql for deduplication
|
|
69
|
+
@id = generate_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Compare findings by key attributes
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.is_a?(Finding) &&
|
|
75
|
+
analyzer_name == other.analyzer_name &&
|
|
76
|
+
rule_name == other.rule_name &&
|
|
77
|
+
severity == other.severity &&
|
|
78
|
+
message == other.message
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Hash based on ID for Set operations
|
|
82
|
+
def hash
|
|
83
|
+
id.hash
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias eql? ==
|
|
87
|
+
|
|
88
|
+
# Serialize to hash for reporting/API
|
|
89
|
+
# Useful for JSON output, CI integration, and telemetry
|
|
90
|
+
def to_h
|
|
91
|
+
{
|
|
92
|
+
id: id,
|
|
93
|
+
analyzer: analyzer_name,
|
|
94
|
+
rule: rule_name,
|
|
95
|
+
severity: severity,
|
|
96
|
+
title: title,
|
|
97
|
+
description: description,
|
|
98
|
+
message: message,
|
|
99
|
+
file_path: file_path,
|
|
100
|
+
line_number: line_number,
|
|
101
|
+
sql: sql,
|
|
102
|
+
metadata: metadata,
|
|
103
|
+
recommendations: recommendations,
|
|
104
|
+
created_at: created_at&.iso8601,
|
|
105
|
+
query: query&.to_h
|
|
106
|
+
}.compact
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Serialize to JSON-friendly hash (excludes large/binary data)
|
|
110
|
+
def to_json_h
|
|
111
|
+
h = to_h
|
|
112
|
+
# Optionally truncate SQL for JSON payloads
|
|
113
|
+
h[:sql] = truncate_sql(h[:sql], 500) if h[:sql]
|
|
114
|
+
h.except(:query) # Exclude full query object from JSON
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Human-readable string for logs
|
|
118
|
+
def to_s
|
|
119
|
+
"[#{severity.upcase}] #{analyzer_name}:#{rule_name} - #{message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Detailed log format
|
|
123
|
+
def to_log_s
|
|
124
|
+
parts = [to_s]
|
|
125
|
+
parts << "File: #{file_path}:#{line_number}" if file_path
|
|
126
|
+
parts << "SQL: #{truncate_sql(sql, 100)}" if sql
|
|
127
|
+
parts.join(" | ")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Inspection string
|
|
131
|
+
def inspect
|
|
132
|
+
"#<Finding id=#{id[0, 8]} #{analyzer_name}:#{rule_name} severity=#{severity}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if finding has location information
|
|
136
|
+
def has_location?
|
|
137
|
+
!file_path.nil?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def generate_id
|
|
143
|
+
# Simple deterministic ID: hash of analyzer:rule:sql
|
|
144
|
+
content = "#{analyzer_name}:#{rule_name}:#{sql}:#{file_path}:#{line_number}"
|
|
145
|
+
Digest::SHA256.hexdigest(content)[0, 16]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def truncate_sql(sql_text, length = 100)
|
|
149
|
+
return sql_text if sql_text.nil? || sql_text.length <= length
|
|
150
|
+
"#{sql_text[0, length]}..."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_severity(sev)
|
|
154
|
+
sym = sev.to_sym
|
|
155
|
+
raise ArgumentError, "Invalid severity: #{sev}. Must be one of #{SEVERITIES}" unless SEVERITIES.include?(sym)
|
|
156
|
+
sym
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
require "digest"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Core
|
|
5
|
+
# Factory builders for common finding types.
|
|
6
|
+
# Provides convenient methods to create well-structured findings
|
|
7
|
+
# without manually specifying all parameters.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# finding = FindingBuilders.slow_query(
|
|
11
|
+
# query,
|
|
12
|
+
# duration_ms: 250.5,
|
|
13
|
+
# threshold_ms: 100.0
|
|
14
|
+
# )
|
|
15
|
+
module FindingBuilders
|
|
16
|
+
# Slow query finding
|
|
17
|
+
def self.slow_query(query, duration_ms:, threshold_ms:, **opts)
|
|
18
|
+
Finding.new(
|
|
19
|
+
analyzer_name: :slow_query,
|
|
20
|
+
rule_name: :duration_exceeded,
|
|
21
|
+
severity: opts[:severity] || :warn,
|
|
22
|
+
title: "Slow Query Detected",
|
|
23
|
+
description: "Query execution time exceeded the configured threshold.",
|
|
24
|
+
message: "Query took #{duration_ms.round(2)}ms (limit: #{threshold_ms}ms)",
|
|
25
|
+
sql: query&.sql,
|
|
26
|
+
metadata: {
|
|
27
|
+
duration_ms: duration_ms,
|
|
28
|
+
threshold_ms: threshold_ms
|
|
29
|
+
},
|
|
30
|
+
recommendations: [
|
|
31
|
+
"Add indexes on frequently queried columns",
|
|
32
|
+
"Review and optimize the query logic",
|
|
33
|
+
"Consider using pagination for large result sets",
|
|
34
|
+
"Check database statistics"
|
|
35
|
+
],
|
|
36
|
+
query: query,
|
|
37
|
+
file_path: opts[:file_path],
|
|
38
|
+
line_number: opts[:line_number]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Too many queries finding
|
|
43
|
+
def self.too_many_queries(count:, limit:, total_duration_ms:, **opts)
|
|
44
|
+
Finding.new(
|
|
45
|
+
analyzer_name: :query_count,
|
|
46
|
+
rule_name: :count_exceeded,
|
|
47
|
+
severity: opts[:severity] || :warn,
|
|
48
|
+
title: "Too Many Queries",
|
|
49
|
+
description: "Request executed more queries than the configured limit.",
|
|
50
|
+
message: "Executed #{count} queries (limit: #{limit})",
|
|
51
|
+
metadata: {
|
|
52
|
+
count: count,
|
|
53
|
+
limit: limit,
|
|
54
|
+
total_duration_ms: total_duration_ms
|
|
55
|
+
},
|
|
56
|
+
recommendations: [
|
|
57
|
+
"Use eager loading (N+1 query prevention)",
|
|
58
|
+
"Consolidate multiple queries into one",
|
|
59
|
+
"Use database-level aggregations where possible",
|
|
60
|
+
"Consider caching results"
|
|
61
|
+
],
|
|
62
|
+
file_path: opts[:file_path],
|
|
63
|
+
line_number: opts[:line_number]
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# SELECT * finding
|
|
68
|
+
def self.select_star(query, **opts)
|
|
69
|
+
Finding.new(
|
|
70
|
+
analyzer_name: :select_star,
|
|
71
|
+
rule_name: :select_star_detected,
|
|
72
|
+
severity: opts[:severity] || :warn,
|
|
73
|
+
title: "SELECT * Used",
|
|
74
|
+
description: "Query uses SELECT * instead of specifying columns.",
|
|
75
|
+
message: "SELECT * detected in query",
|
|
76
|
+
sql: query&.sql,
|
|
77
|
+
metadata: {
|
|
78
|
+
sql: query&.sql
|
|
79
|
+
},
|
|
80
|
+
recommendations: [
|
|
81
|
+
"Specify only required columns explicitly",
|
|
82
|
+
"Reduces bandwidth and improves query efficiency",
|
|
83
|
+
"Makes schema changes less error-prone",
|
|
84
|
+
"Enables better query optimization by the database"
|
|
85
|
+
],
|
|
86
|
+
query: query,
|
|
87
|
+
file_path: opts[:file_path],
|
|
88
|
+
line_number: opts[:line_number]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Generic builder for custom findings
|
|
93
|
+
def self.build(analyzer_name:, rule_name:, **opts)
|
|
94
|
+
Finding.new(
|
|
95
|
+
analyzer_name: analyzer_name,
|
|
96
|
+
rule_name: rule_name,
|
|
97
|
+
severity: opts[:severity] || :warn,
|
|
98
|
+
title: opts[:title],
|
|
99
|
+
description: opts[:description],
|
|
100
|
+
message: opts[:message],
|
|
101
|
+
sql: opts[:sql],
|
|
102
|
+
metadata: opts[:metadata] || {},
|
|
103
|
+
recommendations: opts[:recommendations] || [],
|
|
104
|
+
query: opts[:query],
|
|
105
|
+
file_path: opts[:file_path],
|
|
106
|
+
line_number: opts[:line_number]
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Migration-related finding (prepared for Phase 2)
|
|
111
|
+
def self.migration_risk(migration_file:, issue:, **opts)
|
|
112
|
+
Finding.new(
|
|
113
|
+
analyzer_name: :migration_safety,
|
|
114
|
+
rule_name: opts[:rule_name] || :unsafe_operation,
|
|
115
|
+
severity: opts[:severity] || :error,
|
|
116
|
+
title: opts[:title] || "Migration Risk Detected",
|
|
117
|
+
description: opts[:description] || "Migration may cause data loss or downtime.",
|
|
118
|
+
message: issue,
|
|
119
|
+
file_path: migration_file,
|
|
120
|
+
line_number: opts[:line_number],
|
|
121
|
+
metadata: opts[:metadata] || {},
|
|
122
|
+
recommendations: opts[:recommendations] || [
|
|
123
|
+
"Review migration carefully before deploying",
|
|
124
|
+
"Test on a staging environment",
|
|
125
|
+
"Consider phased rollout for large tables"
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Pattern matching finding (prepared for future analyzers)
|
|
131
|
+
def self.pattern_detected(query, pattern_type:, **opts)
|
|
132
|
+
Finding.new(
|
|
133
|
+
analyzer_name: opts[:analyzer_name] || :pattern_detector,
|
|
134
|
+
rule_name: opts[:rule_name] || :pattern_detected,
|
|
135
|
+
severity: opts[:severity] || :info,
|
|
136
|
+
title: opts[:title] || "Pattern Detected",
|
|
137
|
+
description: opts[:description] || "A query pattern was detected.",
|
|
138
|
+
message: opts[:message] || "#{pattern_type} pattern detected",
|
|
139
|
+
sql: query&.sql,
|
|
140
|
+
metadata: {
|
|
141
|
+
pattern_type: pattern_type,
|
|
142
|
+
**(opts[:metadata] || {})
|
|
143
|
+
},
|
|
144
|
+
recommendations: opts[:recommendations] || [],
|
|
145
|
+
query: query,
|
|
146
|
+
file_path: opts[:file_path],
|
|
147
|
+
line_number: opts[:line_number]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable representation of a single SQL query event.
|
|
6
|
+
# Captured from ActiveSupport::Notifications sql.active_record event.
|
|
7
|
+
class Query
|
|
8
|
+
attr_reader :sql, :duration_ms, :name, :started_at, :finished_at
|
|
9
|
+
|
|
10
|
+
def initialize(sql:, duration_ms:, name: nil, started_at: nil, finished_at: nil)
|
|
11
|
+
@sql = sql.freeze
|
|
12
|
+
@duration_ms = duration_ms
|
|
13
|
+
@name = name.to_s.freeze if name
|
|
14
|
+
@started_at = started_at
|
|
15
|
+
@finished_at = finished_at
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Serialize to hash for reporting
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
sql: sql,
|
|
22
|
+
duration_ms: duration_ms,
|
|
23
|
+
name: name,
|
|
24
|
+
started_at: started_at&.iso8601,
|
|
25
|
+
finished_at: finished_at&.iso8601
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inspect
|
|
30
|
+
"#<Query duration_ms=#{duration_ms} sql='#{truncate_sql(sql, 50)}'>"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def truncate_sql(sql, length = 100)
|
|
36
|
+
sql.length > length ? "#{sql[0, length]}..." : sql
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Explain
|
|
5
|
+
# Base interface for database-specific EXPLAIN plan adapters.
|
|
6
|
+
# Implementations should handle:
|
|
7
|
+
# - Safe query execution without side effects
|
|
8
|
+
# - Plan data extraction in JSON format
|
|
9
|
+
# - Graceful error handling
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# adapter = PostgreSQLAdapter.new(connection)
|
|
13
|
+
# plan = adapter.get_plan("SELECT * FROM users")
|
|
14
|
+
# # => { "Plan" => { "Node Type" => "Seq Scan", ... }, ... }
|
|
15
|
+
class AdapterInterface
|
|
16
|
+
# Initialize adapter with database connection
|
|
17
|
+
#
|
|
18
|
+
# @param connection [Object] Database-specific connection object
|
|
19
|
+
def initialize(connection)
|
|
20
|
+
@connection = connection
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get EXPLAIN plan for a query (JSON format when possible)
|
|
24
|
+
#
|
|
25
|
+
# @param sql [String] SQL query to analyze
|
|
26
|
+
# @param options [Hash] Adapter-specific options
|
|
27
|
+
# @return [Hash] Parsed plan JSON or nil if unavailable
|
|
28
|
+
# @raise [QueryGuard::Explain::AdapterError] on execution failure
|
|
29
|
+
#
|
|
30
|
+
# Example return structure (PostgreSQL):
|
|
31
|
+
# {
|
|
32
|
+
# "Plan" => {
|
|
33
|
+
# "Node Type" => "Seq Scan",
|
|
34
|
+
# "Relation Name" => "users",
|
|
35
|
+
# "Plans" => [...],
|
|
36
|
+
# "Actual Rows" => 100,
|
|
37
|
+
# "Actual Total Time" => 2.543
|
|
38
|
+
# },
|
|
39
|
+
# "Planning Time" => 0.234,
|
|
40
|
+
# "Execution Time" => 2.543
|
|
41
|
+
# }
|
|
42
|
+
def get_plan(sql, options = {})
|
|
43
|
+
raise NotImplementedError, "Subclasses must implement get_plan"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if adapter can execute EXPLAIN for this query type
|
|
47
|
+
#
|
|
48
|
+
# @param sql [String] SQL query
|
|
49
|
+
# @return [Boolean] True if EXPLAIN is supported
|
|
50
|
+
def can_explain?(sql)
|
|
51
|
+
raise NotImplementedError, "Subclasses must implement can_explain?"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get engine name for this adapter
|
|
55
|
+
#
|
|
56
|
+
# @return [Symbol] Engine identifier (:postgresql, :mysql, etc.)
|
|
57
|
+
def engine_name
|
|
58
|
+
raise NotImplementedError, "Subclasses must implement engine_name"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
protected
|
|
62
|
+
|
|
63
|
+
attr_reader :connection
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Custom error for EXPLAIN-related failures
|
|
67
|
+
class AdapterError < StandardError
|
|
68
|
+
attr_reader :query, :original_error
|
|
69
|
+
|
|
70
|
+
def initialize(message, query: nil, original_error: nil)
|
|
71
|
+
super(message)
|
|
72
|
+
@query = query
|
|
73
|
+
@original_error = original_error
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Raised when EXPLAIN execution is not supported for a query
|
|
78
|
+
class UnsupportedQueryError < AdapterError; end
|
|
79
|
+
|
|
80
|
+
# Raised when connection is unavailable
|
|
81
|
+
class ConnectionError < AdapterError; end
|
|
82
|
+
|
|
83
|
+
# Raised when query execution times out
|
|
84
|
+
class TimeoutError < AdapterError; end
|
|
85
|
+
|
|
86
|
+
# Raised when EXPLAIN plan parsing fails
|
|
87
|
+
class PlanParseError < AdapterError; end
|
|
88
|
+
end
|
|
89
|
+
end
|