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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/DESIGN.md +420 -0
  4. data/INDEX.md +309 -0
  5. data/README.md +579 -30
  6. data/exe/queryguard +23 -0
  7. data/lib/query_guard/action_controller_subscriber.rb +27 -0
  8. data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
  9. data/lib/query_guard/analysis/risk_detectors.rb +258 -0
  10. data/lib/query_guard/analysis/risk_level.rb +35 -0
  11. data/lib/query_guard/analyzers/base.rb +30 -0
  12. data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
  13. data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
  14. data/lib/query_guard/analyzers/registry.rb +57 -0
  15. data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
  16. data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
  17. data/lib/query_guard/budget.rb +148 -0
  18. data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
  19. data/lib/query_guard/cli/command.rb +93 -0
  20. data/lib/query_guard/cli/commands/analyze.rb +52 -0
  21. data/lib/query_guard/cli/commands/check.rb +58 -0
  22. data/lib/query_guard/cli/formatter.rb +278 -0
  23. data/lib/query_guard/cli/json_reporter.rb +247 -0
  24. data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
  25. data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
  26. data/lib/query_guard/cli.rb +197 -0
  27. data/lib/query_guard/client.rb +4 -6
  28. data/lib/query_guard/config.rb +145 -6
  29. data/lib/query_guard/core/context.rb +80 -0
  30. data/lib/query_guard/core/finding.rb +162 -0
  31. data/lib/query_guard/core/finding_builders.rb +152 -0
  32. data/lib/query_guard/core/query.rb +40 -0
  33. data/lib/query_guard/explain/adapter_interface.rb +89 -0
  34. data/lib/query_guard/explain/explain_enricher.rb +367 -0
  35. data/lib/query_guard/explain/plan_signals.rb +385 -0
  36. data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
  37. data/lib/query_guard/exporter.rb +124 -0
  38. data/lib/query_guard/fingerprint.rb +96 -0
  39. data/lib/query_guard/middleware.rb +101 -15
  40. data/lib/query_guard/migrations/database_adapter.rb +88 -0
  41. data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
  42. data/lib/query_guard/migrations/migration_risk_detectors.rb +390 -0
  43. data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
  44. data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
  45. data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
  46. data/lib/query_guard/publish.rb +38 -0
  47. data/lib/query_guard/rspec.rb +119 -0
  48. data/lib/query_guard/security.rb +99 -0
  49. data/lib/query_guard/store.rb +38 -0
  50. data/lib/query_guard/subscriber.rb +46 -15
  51. data/lib/query_guard/suggest/index_suggester.rb +176 -0
  52. data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
  53. data/lib/query_guard/trace.rb +106 -0
  54. data/lib/query_guard/uploader/http_uploader.rb +166 -0
  55. data/lib/query_guard/uploader/interface.rb +79 -0
  56. data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
  57. data/lib/query_guard/uploader/registry.rb +37 -0
  58. data/lib/query_guard/uploader/upload_service.rb +80 -0
  59. data/lib/query_guard/version.rb +1 -1
  60. data/lib/query_guard.rb +54 -7
  61. metadata +78 -10
  62. data/.rspec +0 -3
  63. data/Rakefile +0 -21
  64. 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