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,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