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,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analysis
|
|
5
|
+
# Query risk classifier and aggregator.
|
|
6
|
+
# Orchestrates all risk detectors and produces findings.
|
|
7
|
+
class QueryRiskClassifier
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
@detectors = [
|
|
11
|
+
SelectStarRiskDetector.new,
|
|
12
|
+
MissingIndexRiskDetector.new,
|
|
13
|
+
ComplexJoinRiskDetector.new,
|
|
14
|
+
SubqueryRiskDetector.new,
|
|
15
|
+
UnionRiskDetector.new,
|
|
16
|
+
AggregationRiskDetector.new
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Analyze a single query for risks
|
|
21
|
+
# @param query [Core::Query]
|
|
22
|
+
# @return [Array<Hash>] Detected risks
|
|
23
|
+
def analyze_query(query)
|
|
24
|
+
risks = []
|
|
25
|
+
@detectors.each do |detector|
|
|
26
|
+
risks.concat(detector.detect(query, @config))
|
|
27
|
+
end
|
|
28
|
+
risks
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Analyze all queries in context for request-level risks (N+1, repeated queries)
|
|
32
|
+
# @param context [Core::Context]
|
|
33
|
+
# @return [Array<Hash>] Request-level risks
|
|
34
|
+
def analyze_context_risks(context)
|
|
35
|
+
risks = []
|
|
36
|
+
|
|
37
|
+
# Detect potentially repeated queries (same SQL pattern)
|
|
38
|
+
risks.concat(detect_repeated_queries(context))
|
|
39
|
+
|
|
40
|
+
# Detect potential N+1 pattern: many queries in sequence
|
|
41
|
+
risks.concat(detect_n_plus_one_pattern(context))
|
|
42
|
+
|
|
43
|
+
risks
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def detect_repeated_queries(context)
|
|
49
|
+
risks = []
|
|
50
|
+
return risks if context.queries.length < 2
|
|
51
|
+
|
|
52
|
+
# Group queries by normalized SQL
|
|
53
|
+
query_groups = context.queries.group_by { |q| normalize_sql(q.sql) }
|
|
54
|
+
|
|
55
|
+
# Find SQL patterns executed multiple times
|
|
56
|
+
query_groups.each do |_normalized_sql, queries|
|
|
57
|
+
next if queries.length < 3
|
|
58
|
+
|
|
59
|
+
risks << {
|
|
60
|
+
pattern: :repeated_query,
|
|
61
|
+
risk_level: :medium,
|
|
62
|
+
message: "Query executed #{queries.length} times in single request; possible N+1 or missing eager load",
|
|
63
|
+
metadata: {
|
|
64
|
+
count: queries.length,
|
|
65
|
+
duration_total_ms: queries.sum(&:duration_ms),
|
|
66
|
+
recommendation: "Use eager loading (e.g., .includes) or batch queries",
|
|
67
|
+
impact: "Unnecessary query overhead"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
risks
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def detect_n_plus_one_pattern(context)
|
|
76
|
+
risks = []
|
|
77
|
+
return risks if context.queries.length < 5
|
|
78
|
+
|
|
79
|
+
# Simple heuristic: many SELECT FROM single table queries in sequence
|
|
80
|
+
select_queries = context.queries.select { |q| q.sql.match?(/^SELECT\b/i) }
|
|
81
|
+
return risks if select_queries.length < 5
|
|
82
|
+
|
|
83
|
+
# Heuristic: similar query patterns (likely same table)
|
|
84
|
+
patterns = select_queries.map { |q| extract_table_name(q.sql) }.compact.uniq
|
|
85
|
+
return risks if patterns.length < 2
|
|
86
|
+
|
|
87
|
+
# If we have many queries (5+) hitting few tables, likely N+1
|
|
88
|
+
select_count = select_queries.length
|
|
89
|
+
if select_count >= 5
|
|
90
|
+
avg_duration = context.total_duration_ms / context.queries.length
|
|
91
|
+
risks << {
|
|
92
|
+
pattern: :potential_n_plus_one,
|
|
93
|
+
risk_level: :high,
|
|
94
|
+
message: "Detected #{select_count} SELECT queries hitting ~#{patterns.length} table(s); likely N+1 query problem",
|
|
95
|
+
metadata: {
|
|
96
|
+
select_count: select_count,
|
|
97
|
+
table_count: patterns.length,
|
|
98
|
+
avg_query_ms: avg_duration.round(2),
|
|
99
|
+
recommendation: "Use eager loading (.includes, .joins) or batch queries",
|
|
100
|
+
impact: "Performance degradation with data scale"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
risks
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_sql(sql)
|
|
109
|
+
sql.upcase.gsub(/\d+/, "?").gsub(/\s+/, " ").strip
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_table_name(sql)
|
|
113
|
+
# Simple heuristic: first table after FROM
|
|
114
|
+
if sql.match?(/\bFROM\s+(\w+)/i)
|
|
115
|
+
sql.match(/\bFROM\s+(\w+)/i)[1].downcase
|
|
116
|
+
elsif sql.match?(/\bINTO\s+(\w+)/i)
|
|
117
|
+
sql.match(/\bINTO\s+(\w+)/i)[1].downcase
|
|
118
|
+
else
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analysis
|
|
5
|
+
# Base class for query risk detectors.
|
|
6
|
+
# Detectors analyze specific SQL patterns and return risks.
|
|
7
|
+
class RiskDetector
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
def initialize(name)
|
|
11
|
+
@name = name.to_sym
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Analyze a query and return risks
|
|
15
|
+
# @param query [Core::Query] The query to analyze
|
|
16
|
+
# @param config [Config] Configuration with thresholds
|
|
17
|
+
# @return [Array<Hash>] List of detected risks with :pattern, :risk_level, :message, :metadata
|
|
18
|
+
def detect(query, config)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #detect"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
# Extract SQL normalized (uppercase, whitespace normalized)
|
|
25
|
+
def normalize_sql(sql)
|
|
26
|
+
sql.upcase.gsub(/\s+/, " ").strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if pattern matches (case-insensitive regex)
|
|
30
|
+
def matches_pattern?(sql, pattern)
|
|
31
|
+
pattern.match?(sql)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Detects SELECT * usage patterns
|
|
36
|
+
class SelectStarRiskDetector < RiskDetector
|
|
37
|
+
SELECT_STAR_PATTERN = /\bSELECT\s+\*/i.freeze
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
super(:select_star_risk)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def detect(query, config)
|
|
44
|
+
risks = []
|
|
45
|
+
return risks unless matches_pattern?(query.sql, SELECT_STAR_PATTERN)
|
|
46
|
+
|
|
47
|
+
risks << {
|
|
48
|
+
pattern: :select_star,
|
|
49
|
+
risk_level: :medium,
|
|
50
|
+
message: "SELECT * can fetch unnecessary columns, increasing bandwidth and reducing query efficiency",
|
|
51
|
+
metadata: {
|
|
52
|
+
recommendation: "Specify only required columns explicitly",
|
|
53
|
+
impact: "Unnecessary data transfer; harder schema evolution"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
risks
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Detects missing table alias patterns (likely missing indexes)
|
|
62
|
+
class MissingIndexRiskDetector < RiskDetector
|
|
63
|
+
# Patterns that often indicate missing indexes
|
|
64
|
+
FULL_TABLE_SCAN_INDICATORS = [
|
|
65
|
+
/\bWHERE\b.*\b(?:LIKE|ILIKE|REGEXP)\b/i, # LIKE on non-indexed column
|
|
66
|
+
/\bON\b.*\b(?:FUNCTION|CAST|COALESCE)\b/i # Function in JOIN condition
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
def initialize
|
|
70
|
+
super(:missing_index_risk)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def detect(query, config)
|
|
74
|
+
risks = []
|
|
75
|
+
sql = query.sql
|
|
76
|
+
|
|
77
|
+
# Simple heuristic: LIKE without index hint
|
|
78
|
+
if matches_pattern?(sql, FULL_TABLE_SCAN_INDICATORS[0])
|
|
79
|
+
risks << {
|
|
80
|
+
pattern: :like_without_index,
|
|
81
|
+
risk_level: :high,
|
|
82
|
+
message: "LIKE pattern matching often requires full table scan; consider full-text search or indexed prefix matching",
|
|
83
|
+
metadata: {
|
|
84
|
+
recommendation: "Index column(s) or use full-text search",
|
|
85
|
+
impact: "Sequential scan on large tables"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Function in JOIN condition
|
|
91
|
+
if matches_pattern?(sql, FULL_TABLE_SCAN_INDICATORS[1])
|
|
92
|
+
risks << {
|
|
93
|
+
pattern: :function_in_join,
|
|
94
|
+
risk_level: :high,
|
|
95
|
+
message: "Functions in JOIN conditions prevent index usage, causing sequential scans",
|
|
96
|
+
metadata: {
|
|
97
|
+
recommendation: "Move function outside JOIN condition",
|
|
98
|
+
impact: "Index cannot be used"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
risks
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Detects patterns indicative of N+1 or repeated queries
|
|
108
|
+
class RepeatedQueryRiskDetector < RiskDetector
|
|
109
|
+
def initialize
|
|
110
|
+
super(:repeated_query_risk)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Note: Can only detect within current request context
|
|
114
|
+
def detect(query, config)
|
|
115
|
+
risks = []
|
|
116
|
+
# Detected at request level by QueryRiskAnalyzer
|
|
117
|
+
# Return empty for now; actual detection happens in orchestrator
|
|
118
|
+
risks
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Detects complex join patterns
|
|
123
|
+
class ComplexJoinRiskDetector < RiskDetector
|
|
124
|
+
# 5+ tables is generally considered complex
|
|
125
|
+
COMPLEX_JOIN_TABLE_COUNT = 5
|
|
126
|
+
|
|
127
|
+
def initialize
|
|
128
|
+
super(:complex_join_risk)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def detect(query, config)
|
|
132
|
+
risks = []
|
|
133
|
+
sql = query.sql
|
|
134
|
+
|
|
135
|
+
# Count JOIN keywords (crude heuristic)
|
|
136
|
+
join_count = sql.scan(/\bJOIN\b/i).length
|
|
137
|
+
return risks if join_count < COMPLEX_JOIN_TABLE_COUNT
|
|
138
|
+
|
|
139
|
+
risks << {
|
|
140
|
+
pattern: :many_joins,
|
|
141
|
+
risk_level: :medium,
|
|
142
|
+
message: "Query with #{join_count + 1} tables; complex joins can be slow and hard to optimize",
|
|
143
|
+
metadata: {
|
|
144
|
+
table_count: join_count + 1,
|
|
145
|
+
recommendation: "Consider breaking into multiple queries or database view",
|
|
146
|
+
impact: "Performance degradation with scale"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
risks
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Detects subquery patterns
|
|
155
|
+
class SubqueryRiskDetector < RiskDetector
|
|
156
|
+
SUBQUERY_PATTERN = /\(SELECT\b/i.freeze
|
|
157
|
+
|
|
158
|
+
def initialize
|
|
159
|
+
super(:subquery_risk)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def detect(query, config)
|
|
163
|
+
risks = []
|
|
164
|
+
return risks unless matches_pattern?(query.sql, SUBQUERY_PATTERN)
|
|
165
|
+
|
|
166
|
+
# Count subqueries
|
|
167
|
+
subquery_count = query.sql.scan(/\(SELECT\b/i).length
|
|
168
|
+
return risks if subquery_count.zero?
|
|
169
|
+
|
|
170
|
+
# Nested subqueries are more risky
|
|
171
|
+
risk_level = subquery_count > 2 ? :high : :medium
|
|
172
|
+
|
|
173
|
+
risks << {
|
|
174
|
+
pattern: :nested_subqueries,
|
|
175
|
+
risk_level: risk_level,
|
|
176
|
+
message: "Query contains #{subquery_count} subquery(ies); consider using JOINs or CTE for better performance",
|
|
177
|
+
metadata: {
|
|
178
|
+
subquery_count: subquery_count,
|
|
179
|
+
recommendation: "Replace with JOIN or WITH clause",
|
|
180
|
+
impact: "Multiple table scans"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
risks
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Detects UNION queries (can be slower than OR)
|
|
189
|
+
class UnionRiskDetector < RiskDetector
|
|
190
|
+
UNION_PATTERN = /\bUNION\b/i.freeze
|
|
191
|
+
|
|
192
|
+
def initialize
|
|
193
|
+
super(:union_risk)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def detect(query, config)
|
|
197
|
+
risks = []
|
|
198
|
+
return risks unless matches_pattern?(query.sql, UNION_PATTERN)
|
|
199
|
+
|
|
200
|
+
# UNION ALL is better than UNION (no sorting)
|
|
201
|
+
has_union_all = query.sql.match?(/\bUNION\s+ALL\b/i)
|
|
202
|
+
|
|
203
|
+
risks << {
|
|
204
|
+
pattern: :union_query,
|
|
205
|
+
risk_level: has_union_all ? :low : :medium,
|
|
206
|
+
message: "UNION #{'ALL' if has_union_all} query; consider OR clause or application-level merging",
|
|
207
|
+
metadata: {
|
|
208
|
+
has_union_all: has_union_all,
|
|
209
|
+
recommendation: has_union_all ? "Consider OR instead" : "Use UNION ALL instead of UNION",
|
|
210
|
+
impact: "Sorting overhead if UNION (not ALL)"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
risks
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Detects GROUP BY / DISTINCT patterns
|
|
219
|
+
class AggregationRiskDetector < RiskDetector
|
|
220
|
+
def initialize
|
|
221
|
+
super(:aggregation_risk)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def detect(query, config)
|
|
225
|
+
risks = []
|
|
226
|
+
sql = query.sql
|
|
227
|
+
|
|
228
|
+
# DISTINCT on large columns
|
|
229
|
+
if query.sql.match?(/\bSELECT\s+DISTINCT\b/i)
|
|
230
|
+
risks << {
|
|
231
|
+
pattern: :distinct_usage,
|
|
232
|
+
risk_level: :low,
|
|
233
|
+
message: "DISTINCT forces sorting; ensure needed and indexed properly",
|
|
234
|
+
metadata: {
|
|
235
|
+
recommendation: "Verify uniqueness constraint or consider GROUP BY",
|
|
236
|
+
impact: "Sorting overhead"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# GROUP BY without ORDER BY (random order)
|
|
242
|
+
if sql.match?(/\bGROUP\s+BY\b/i) && !sql.match?(/\bORDER\s+BY\b/i)
|
|
243
|
+
risks << {
|
|
244
|
+
pattern: :group_by_unordered,
|
|
245
|
+
risk_level: :low,
|
|
246
|
+
message: "GROUP BY without ORDER BY returns results in random order; add ORDER BY if order matters",
|
|
247
|
+
metadata: {
|
|
248
|
+
recommendation: "Add ORDER BY if result order is important",
|
|
249
|
+
impact: "Unpredictable result ordering"
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
risks
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analysis
|
|
5
|
+
# Risk severity levels for query analysis
|
|
6
|
+
# Used to classify and report on query risks
|
|
7
|
+
class RiskLevel
|
|
8
|
+
LEVELS = {
|
|
9
|
+
low: 1,
|
|
10
|
+
medium: 2,
|
|
11
|
+
high: 3,
|
|
12
|
+
critical: 4
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
SEVERITY_MAP = {
|
|
16
|
+
low: :info,
|
|
17
|
+
medium: :warn,
|
|
18
|
+
high: :error,
|
|
19
|
+
critical: :error
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def self.valid?(level)
|
|
23
|
+
LEVELS.key?(level.to_sym)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.to_severity(level)
|
|
27
|
+
SEVERITY_MAP[level.to_sym] || :warn
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.compare(level1, level2)
|
|
31
|
+
LEVELS[level1.to_sym] <=> LEVELS[level2.to_sym]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Base class for all rule-based analyzers.
|
|
6
|
+
# Subclasses implement `analyze(context, config)` to produce findings.
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
def initialize(name)
|
|
11
|
+
@name = name.to_sym
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Analyze the context and produce findings.
|
|
15
|
+
# Must return an array of Finding objects.
|
|
16
|
+
#
|
|
17
|
+
# @param context [Core::Context] Contains queries and metadata
|
|
18
|
+
# @param config [Config] Configuration object
|
|
19
|
+
# @return [Array<Core::Finding>] List of findings
|
|
20
|
+
def analyze(context, config)
|
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #analyze"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Enabled check - allows per-analyzer enable/disable
|
|
25
|
+
def enabled?(config)
|
|
26
|
+
!config.disabled_analyzers&.include?(name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Detects when query count per request exceeds threshold.
|
|
6
|
+
class QueryCountAnalyzer < Base
|
|
7
|
+
def initialize
|
|
8
|
+
super(:query_count)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def analyze(context, config)
|
|
12
|
+
findings = []
|
|
13
|
+
limit = config.max_queries_per_request
|
|
14
|
+
|
|
15
|
+
return findings if limit.nil?
|
|
16
|
+
|
|
17
|
+
count = context.queries.length
|
|
18
|
+
return findings unless count > limit
|
|
19
|
+
|
|
20
|
+
findings << Core::FindingBuilders.too_many_queries(
|
|
21
|
+
count: count,
|
|
22
|
+
limit: limit,
|
|
23
|
+
total_duration_ms: context.total_duration_ms,
|
|
24
|
+
severity: config.query_count_severity || :warn
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
findings
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Analyzes queries for performance and safety risks.
|
|
6
|
+
# Evaluates SQL patterns, missing indexes, N+1 patterns, and other risks.
|
|
7
|
+
# Optionally enriches findings with EXPLAIN plan analysis (PostgreSQL).
|
|
8
|
+
class QueryRiskAnalyzer < Base
|
|
9
|
+
def initialize
|
|
10
|
+
super(:query_risk)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def analyze(context, config)
|
|
14
|
+
findings = []
|
|
15
|
+
# Only analyze if enabled
|
|
16
|
+
return findings unless config.analyze_query_risks
|
|
17
|
+
|
|
18
|
+
classifier = Analysis::QueryRiskClassifier.new(config)
|
|
19
|
+
|
|
20
|
+
# Analyze each query individually
|
|
21
|
+
context.queries.each do |query|
|
|
22
|
+
risks = classifier.analyze_query(query)
|
|
23
|
+
query_findings = risks_to_findings(risks, query, config)
|
|
24
|
+
|
|
25
|
+
# Enrich with EXPLAIN analysis if enabled
|
|
26
|
+
if config.use_explain_plans && config.explain_enricher
|
|
27
|
+
query_findings = config.explain_enricher.enrich(query_findings, query, context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
findings.concat(query_findings)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Analyze request-level patterns (N+1, repeated queries)
|
|
34
|
+
context_risks = classifier.analyze_context_risks(context)
|
|
35
|
+
findings.concat(context_risks_to_findings(context_risks, config))
|
|
36
|
+
|
|
37
|
+
findings
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def risks_to_findings(risks, query, config)
|
|
43
|
+
findings = []
|
|
44
|
+
|
|
45
|
+
risks.each do |risk|
|
|
46
|
+
severity = Analysis::RiskLevel.to_severity(risk[:risk_level])
|
|
47
|
+
|
|
48
|
+
finding = Core::FindingBuilders.build(
|
|
49
|
+
analyzer_name: name,
|
|
50
|
+
rule_name: risk[:pattern],
|
|
51
|
+
severity: severity,
|
|
52
|
+
title: risk_title(risk[:pattern]),
|
|
53
|
+
description: risk_description(risk[:pattern]),
|
|
54
|
+
message: risk[:message],
|
|
55
|
+
sql: query.sql,
|
|
56
|
+
metadata: risk[:metadata],
|
|
57
|
+
recommendations: [risk[:metadata][:recommendation]],
|
|
58
|
+
query: query
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
findings << finding
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
findings
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def context_risks_to_findings(risks, config)
|
|
68
|
+
findings = []
|
|
69
|
+
|
|
70
|
+
risks.each do |risk|
|
|
71
|
+
severity = Analysis::RiskLevel.to_severity(risk[:risk_level])
|
|
72
|
+
|
|
73
|
+
finding = Core::FindingBuilders.build(
|
|
74
|
+
analyzer_name: name,
|
|
75
|
+
rule_name: risk[:pattern],
|
|
76
|
+
severity: severity,
|
|
77
|
+
title: risk_title(risk[:pattern]),
|
|
78
|
+
description: risk_description(risk[:pattern]),
|
|
79
|
+
message: risk[:message],
|
|
80
|
+
metadata: risk[:metadata],
|
|
81
|
+
recommendations: [risk[:metadata][:recommendation]]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
findings << finding
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
findings
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def risk_title(pattern)
|
|
91
|
+
case pattern
|
|
92
|
+
when :select_star
|
|
93
|
+
"SELECT * Usage"
|
|
94
|
+
when :like_without_index
|
|
95
|
+
"LIKE Without Index"
|
|
96
|
+
when :function_in_join
|
|
97
|
+
"Function in JOIN Condition"
|
|
98
|
+
when :many_joins
|
|
99
|
+
"Complex Multi-Table Join"
|
|
100
|
+
when :nested_subqueries
|
|
101
|
+
"Nested Subqueries"
|
|
102
|
+
when :union_query
|
|
103
|
+
"UNION Query"
|
|
104
|
+
when :distinct_usage
|
|
105
|
+
"DISTINCT Clause"
|
|
106
|
+
when :group_by_unordered
|
|
107
|
+
"GROUP BY Without ORDER BY"
|
|
108
|
+
when :repeated_query
|
|
109
|
+
"Repeated Query in Request"
|
|
110
|
+
when :potential_n_plus_one
|
|
111
|
+
"Potential N+1 Query Problem"
|
|
112
|
+
else
|
|
113
|
+
pattern.to_s.titleize
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def risk_description(pattern)
|
|
118
|
+
case pattern
|
|
119
|
+
when :select_star
|
|
120
|
+
"Selecting all columns can fetch unnecessary data and reduce efficiency."
|
|
121
|
+
when :like_without_index
|
|
122
|
+
"LIKE pattern matching typically requires full table scans."
|
|
123
|
+
when :function_in_join
|
|
124
|
+
"Functions in JOIN conditions prevent index usage."
|
|
125
|
+
when :many_joins
|
|
126
|
+
"Queries joining many tables are complex and difficult to optimize."
|
|
127
|
+
when :nested_subqueries
|
|
128
|
+
"Nested subqueries can cause multiple table scans."
|
|
129
|
+
when :union_query
|
|
130
|
+
"UNION requires sorting; UNION ALL is often faster."
|
|
131
|
+
when :distinct_usage
|
|
132
|
+
"DISTINCT forces sorting and can be expensive."
|
|
133
|
+
when :group_by_unordered
|
|
134
|
+
"GROUP BY without ORDER BY returns results in unpredictable order."
|
|
135
|
+
when :repeated_query
|
|
136
|
+
"Same query executed multiple times suggests missing eager loading."
|
|
137
|
+
when :potential_n_plus_one
|
|
138
|
+
"Many sequential queries in a single request indicates an N+1 problem."
|
|
139
|
+
else
|
|
140
|
+
pattern.to_s.titleize
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Registry for managing analyzer instances.
|
|
6
|
+
# Provides global registration and per-request analysis coordination.
|
|
7
|
+
class Registry
|
|
8
|
+
def initialize
|
|
9
|
+
@analyzers = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register an analyzer instance
|
|
13
|
+
# @param name [Symbol] Unique identifier for the analyzer
|
|
14
|
+
# @param analyzer [Base] Analyzer instance
|
|
15
|
+
def register(name, analyzer)
|
|
16
|
+
raise ArgumentError, "Analyzer must inherit from Base" unless analyzer.is_a?(Base)
|
|
17
|
+
@analyzers[name.to_sym] = analyzer
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Retrieve analyzer by name
|
|
21
|
+
# @param name [Symbol] Analyzer name
|
|
22
|
+
# @return [Base, nil] Analyzer instance or nil if not found
|
|
23
|
+
def get(name)
|
|
24
|
+
@analyzers[name.to_sym]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if analyzer is registered
|
|
28
|
+
def registered?(name)
|
|
29
|
+
@analyzers.key?(name.to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get all registered analyzers
|
|
33
|
+
# @return [Hash{Symbol => Base}]
|
|
34
|
+
def all
|
|
35
|
+
@analyzers.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run all enabled analyzers against the context
|
|
39
|
+
# @param context [Core::Context] Request context
|
|
40
|
+
# @param config [Config] Configuration
|
|
41
|
+
# @return [Array<Core::Finding>] Combined findings from all analyzers
|
|
42
|
+
def analyze(context, config)
|
|
43
|
+
findings = []
|
|
44
|
+
@analyzers.each do |name, analyzer|
|
|
45
|
+
next unless analyzer.enabled?(config)
|
|
46
|
+
findings.concat(analyzer.analyze(context, config))
|
|
47
|
+
end
|
|
48
|
+
findings
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clear all registered analyzers
|
|
52
|
+
def clear
|
|
53
|
+
@analyzers.clear
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|