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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Analyzers
5
+ # Detects SELECT * statements.
6
+ class SelectStarAnalyzer < Base
7
+ SELECT_STAR_PATTERN = /\bSELECT\s+\*/i.freeze
8
+
9
+ def initialize
10
+ super(:select_star)
11
+ end
12
+
13
+ def analyze(context, config)
14
+ findings = []
15
+
16
+ return findings unless config.block_select_star
17
+
18
+ context.queries.each do |query|
19
+ next unless matches_select_star?(query.sql)
20
+ next if exceeds_ignored_sql?(query.sql, config)
21
+
22
+ findings << Core::FindingBuilders.select_star(
23
+ query,
24
+ severity: config.select_star_severity || :warn
25
+ )
26
+ end
27
+
28
+ findings
29
+ end
30
+
31
+ private
32
+
33
+ def matches_select_star?(sql)
34
+ SELECT_STAR_PATTERN.match?(sql)
35
+ end
36
+
37
+ def exceeds_ignored_sql?(sql, config)
38
+ config.ignored_sql.any? { |pattern| pattern === sql }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Analyzers
5
+ # Detects slow queries based on duration threshold.
6
+ class SlowQueryAnalyzer < Base
7
+ def initialize
8
+ super(:slow_query)
9
+ end
10
+
11
+ def analyze(context, config)
12
+ findings = []
13
+ threshold = config.max_duration_ms_per_query
14
+
15
+ return findings if threshold.nil?
16
+
17
+ context.queries.each do |query|
18
+ next if exceeds_ignored_sql?(query.sql, config)
19
+ next unless query.duration_ms > threshold
20
+
21
+ findings << Core::FindingBuilders.slow_query(
22
+ query,
23
+ duration_ms: query.duration_ms,
24
+ threshold_ms: threshold,
25
+ severity: config.slow_query_severity || :warn
26
+ )
27
+ end
28
+
29
+ findings
30
+ end
31
+
32
+ private
33
+
34
+ def exceeds_ignored_sql?(sql, config)
35
+ config.ignored_sql.any? { |pattern| pattern === sql }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ # Budget system for enforcing query SLOs on controllers and jobs.
5
+ # Supports modes: :log (warn only), :notify (callback), :raise (exception).
6
+ class Budget
7
+ class Violation < StandardError; end
8
+
9
+ attr_reader :rules, :mode, :on_violation
10
+
11
+ def initialize
12
+ @rules = {}
13
+ @mode = :log
14
+ @on_violation = nil
15
+ end
16
+
17
+ # DSL: Define a budget for a specific controller action.
18
+ # Examples:
19
+ # budget.for("users#index", count: 10, duration_ms: 500)
20
+ # budget.for("posts#show", count: 5)
21
+ def for(key, **limits)
22
+ normalized = normalize_key(key)
23
+ @rules[normalized] ||= {}
24
+ @rules[normalized].merge!(limits)
25
+ self
26
+ end
27
+
28
+ # DSL: Define a budget for a background job.
29
+ # Examples:
30
+ # budget.for_job("EmailJob", count: 50, duration_ms: 2000)
31
+ # budget.for_job(EmailJob, count: 50)
32
+ def for_job(job_class_or_name, **limits)
33
+ key = job_class_or_name.is_a?(String) ? job_class_or_name : job_class_or_name.to_s
34
+ normalized = normalize_key("job:#{key}")
35
+ @rules[normalized] ||= {}
36
+ @rules[normalized].merge!(limits)
37
+ self
38
+ end
39
+
40
+ # Set the enforcement mode
41
+ # :log - Log warnings only (default)
42
+ # :notify - Call on_violation callback
43
+ # :raise - Raise Budget::Violation exception
44
+ def mode=(value)
45
+ unless [:log, :notify, :raise].include?(value)
46
+ raise ArgumentError, "Invalid mode: #{value}. Must be :log, :notify, or :raise"
47
+ end
48
+ @mode = value
49
+ end
50
+
51
+ # Set a callback for :notify mode
52
+ def on_violation=(callback)
53
+ unless callback.respond_to?(:call)
54
+ raise ArgumentError, "on_violation must be callable"
55
+ end
56
+ @on_violation = callback
57
+ end
58
+
59
+ # Check if a budget exists for the given key
60
+ def budget_for(key)
61
+ normalized = normalize_key(key)
62
+ @rules[normalized]
63
+ end
64
+
65
+ # Check stats against budget and return violations
66
+ def check(key, stats)
67
+ budget = budget_for(key)
68
+ return [] unless budget
69
+
70
+ violations = []
71
+
72
+ if budget[:count] && stats[:count] > budget[:count]
73
+ violations << {
74
+ type: :budget_exceeded_count,
75
+ key: key,
76
+ actual: stats[:count],
77
+ limit: budget[:count]
78
+ }
79
+ end
80
+
81
+ if budget[:duration_ms] && stats[:total_duration_ms] > budget[:duration_ms]
82
+ violations << {
83
+ type: :budget_exceeded_duration,
84
+ key: key,
85
+ actual: stats[:total_duration_ms],
86
+ limit: budget[:duration_ms]
87
+ }
88
+ end
89
+
90
+ violations
91
+ end
92
+
93
+ # Enforce budget violations according to current mode
94
+ def enforce!(key, violations)
95
+ return if violations.empty?
96
+
97
+ case @mode
98
+ when :log
99
+ log_violations(key, violations)
100
+ when :notify
101
+ notify_violations(key, violations)
102
+ when :raise
103
+ raise_violations(key, violations)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def normalize_key(key)
110
+ key.to_s.downcase.gsub(/\s+/, "")
111
+ end
112
+
113
+ def log_violations(key, violations)
114
+ violations.each do |v|
115
+ message = format_violation(key, v)
116
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
117
+ Rails.logger.warn("[QueryGuard::Budget] #{message}")
118
+ else
119
+ warn("[QueryGuard::Budget] #{message}")
120
+ end
121
+ end
122
+ end
123
+
124
+ def notify_violations(key, violations)
125
+ return unless @on_violation
126
+
127
+ violations.each do |v|
128
+ @on_violation.call(key, v)
129
+ end
130
+ end
131
+
132
+ def raise_violations(key, violations)
133
+ messages = violations.map { |v| format_violation(key, v) }
134
+ raise Violation, messages.join("; ")
135
+ end
136
+
137
+ def format_violation(key, v)
138
+ case v[:type]
139
+ when :budget_exceeded_count
140
+ "#{key}: Query count exceeded (#{v[:actual]} > #{v[:limit]})"
141
+ when :budget_exceeded_duration
142
+ "#{key}: Total duration exceeded (#{v[:actual].round(2)}ms > #{v[:limit]}ms)"
143
+ else
144
+ "#{key}: #{v[:type]}"
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module QueryGuard
7
+ class CLI
8
+ # Formats multiple QueryGuard reports into a single batch request.
9
+ #
10
+ # Used for bulk ingestion into SaaS platforms. Supports:
11
+ # - Multiple reports in a single API request
12
+ # - Batch correlation IDs for tracking across the entire batch
13
+ # - Per-report tracing for individual finding tracking
14
+ # - Pagination metadata for large batches
15
+ #
16
+ # Example:
17
+ # reports = [
18
+ # { report_version: '1.0', findings: [...], ... },
19
+ # { report_version: '1.0', findings: [...], ... }
20
+ # ]
21
+ #
22
+ # batch = BatchReportFormatter.new(
23
+ # reports: reports,
24
+ # batch_id: 'batch-123',
25
+ # correlation_id: 'api-request-456'
26
+ # )
27
+ #
28
+ # json_output = batch.generate
29
+ class BatchReportFormatter
30
+ BATCH_SCHEMA_VERSION = '1.0'
31
+ BATCH_TYPE = 'batch'
32
+
33
+ def initialize(reports:, batch_id: nil, correlation_id: nil, options: {})
34
+ @reports = Array(reports)
35
+ @batch_id = batch_id || generate_batch_id
36
+ @correlation_id = correlation_id || generate_correlation_id
37
+ @options = options
38
+ @created_at = Time.now
39
+ end
40
+
41
+ # Generate the complete batch JSON
42
+ # Returns: String (JSON)
43
+ def generate
44
+ JSON.pretty_generate(build_batch)
45
+ end
46
+
47
+ # Build batch structure and return as hash
48
+ def build_batch
49
+ {
50
+ batch_version: BATCH_SCHEMA_VERSION,
51
+ batch_type: BATCH_TYPE,
52
+ batch_id: @batch_id,
53
+ correlation_id: @correlation_id,
54
+ created_at: @created_at.utc.iso8601.gsub('+00:00', 'Z'),
55
+ report_count: @reports.length,
56
+ reports: @reports,
57
+ pagination: build_pagination,
58
+ stats: build_batch_stats
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ # Pagination metadata for large batches
65
+ # Useful when records exceed size limits (e.g., >10MB)
66
+ def build_pagination
67
+ pagination = {
68
+ total_reports: @reports.length,
69
+ page: @options[:page] || 1,
70
+ page_size: @options[:page_size] || @reports.length,
71
+ has_more: @options[:has_more] || false
72
+ }
73
+
74
+ # Include continuation token if provided (for cursor-based pagination)
75
+ pagination[:continuation_token] = @options[:continuation_token] if @options[:continuation_token]
76
+ pagination[:next_page_token] = @options[:next_page_token] if @options[:next_page_token]
77
+
78
+ pagination
79
+ end
80
+
81
+ # Aggregate statistics across all reports in the batch
82
+ def build_batch_stats
83
+ total_findings = 0
84
+ total_critical = 0
85
+ total_error = 0
86
+ total_warn = 0
87
+ total_info = 0
88
+ by_report_type = { 'analyze' => 0, 'check' => 0 }
89
+
90
+ @reports.each do |report|
91
+ summary = report[:summary] || {}
92
+ total_findings += summary[:total_findings] || 0
93
+ total_critical += summary[:by_severity]&.dig(:critical) || 0
94
+ total_error += summary[:by_severity]&.dig(:error) || 0
95
+ total_warn += summary[:by_severity]&.dig(:warn) || 0
96
+ total_info += summary[:by_severity]&.dig(:info) || 0
97
+
98
+ report_type = report[:report_type] || 'unknown'
99
+ by_report_type[report_type] ||= 0
100
+ by_report_type[report_type] += 1
101
+ end
102
+
103
+ {
104
+ total_findings: total_findings,
105
+ findings_by_severity: {
106
+ critical: total_critical,
107
+ error: total_error,
108
+ warn: total_warn,
109
+ info: total_info
110
+ },
111
+ reports_by_type: by_report_type,
112
+ processing_time_ms: ((@created_at - @created_at) * 1000).round(2)
113
+ }
114
+ end
115
+
116
+ # Helper: Generate a unique batch ID
117
+ def generate_batch_id
118
+ require 'securerandom'
119
+ "batch-#{SecureRandom.hex(8)}"
120
+ end
121
+
122
+ # Helper: Generate a correlation ID for the entire batch
123
+ def generate_correlation_id
124
+ require 'securerandom'
125
+ "corr-#{SecureRandom.hex(8)}"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ class CLI
5
+ # Base class for CLI commands
6
+ class Command
7
+ def initialize(path, options = {})
8
+ @path = path
9
+ @options = options
10
+ @formatter = Formatter.new(options)
11
+ end
12
+
13
+ protected
14
+
15
+ def find_migration_files(path)
16
+ pattern = File.join(path, '**', '*_*.rb')
17
+ Dir.glob(pattern)
18
+ end
19
+
20
+ def find_ruby_files(path)
21
+ pattern = File.join(path, '**', '*.rb')
22
+ Dir.glob(pattern)
23
+ end
24
+
25
+ def analyze_migrations(files)
26
+ analyzer = QueryGuard::Migrations::MigrationAnalyzer.new(
27
+ database_adapter: get_database_adapter
28
+ )
29
+
30
+ findings = []
31
+ files.each do |file|
32
+ begin
33
+ file_findings = analyzer.analyze_migration(file)
34
+ file_findings.each do |finding|
35
+ finding[:file] = file
36
+ findings << finding
37
+ end
38
+ rescue => e
39
+ puts "Warning: Failed to analyze #{file}: #{e.message}" if @options[:verbose]
40
+ end
41
+ end
42
+
43
+ findings
44
+ end
45
+
46
+ def get_database_adapter
47
+ # Try to get a live database adapter if available
48
+ return nil unless defined?(ActiveRecord) && ActiveRecord::Base.connected?
49
+
50
+ begin
51
+ QueryGuard::Migrations::PostgreSQLAdapter.new(
52
+ connection: ActiveRecord::Base.connection
53
+ )
54
+ rescue StandardError
55
+ nil # Fall back to NullDatabaseAdapter
56
+ end
57
+ end
58
+
59
+ def severity_to_number(severity)
60
+ case severity
61
+ when :critical then 4
62
+ when :error then 3
63
+ when :warn then 2
64
+ when :info then 1
65
+ else 0
66
+ end
67
+ end
68
+
69
+ def threshold_to_number(threshold)
70
+ case threshold&.to_s&.downcase
71
+ when 'critical' then 4
72
+ when 'error' then 3
73
+ when 'warn' then 2
74
+ when 'info' then 1
75
+ else 2 # Default to warn
76
+ end
77
+ end
78
+
79
+ def findings_exceed_threshold?(findings, threshold)
80
+ threshold_num = threshold_to_number(threshold)
81
+ findings.any? { |f| severity_to_number(f[:severity]) >= threshold_num }
82
+ end
83
+
84
+ def path_exists?
85
+ File.exist?(@path)
86
+ end
87
+
88
+ def path_absolute
89
+ File.expand_path(@path)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ class CLI
5
+ module Commands
6
+ # Analyzes queries and migrations, printing detailed results
7
+ class Analyze < Command
8
+ def execute
9
+ unless path_exists?
10
+ puts "Error: Path does not exist: #{path_absolute}"
11
+ exit 1
12
+ end
13
+
14
+ # Skip progress output if JSON format is being used
15
+ unless @options[:json] || @options[:format] == 'json'
16
+ puts "Analyzing: #{path_absolute}"
17
+
18
+ # Show database context
19
+ adapter = get_database_adapter
20
+ if adapter
21
+ puts "Database context: Connected (accurate severity)\n"
22
+ else
23
+ puts "Database context: Not available (conservative estimates)"
24
+ puts " For more accurate results, run: DATABASE_URL=... queryguard analyze\n"
25
+ end
26
+
27
+ puts "(This may take a moment...)\n"
28
+ end
29
+
30
+ # Find and analyze migration files
31
+ migration_files = find_migration_files(@path)
32
+ if migration_files.any?
33
+ puts " Found #{migration_files.length} migration files" unless @options[:json] || @options[:format] == 'json'
34
+ findings = analyze_migrations(migration_files)
35
+ else
36
+ findings = []
37
+ end
38
+
39
+ # Format and print results
40
+ if findings.any?
41
+ @formatter.print_findings(findings, "Migration Risk Analysis Results", 'analyze', @path)
42
+ else
43
+ puts "\n✅ No issues found in migrations!\n" unless @options[:json] || @options[:format] == 'json'
44
+ end
45
+
46
+ # Exit with success (analyze doesn't fail on findings)
47
+ 0
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ class CLI
5
+ module Commands
6
+ # Checks if findings exceed severity threshold for CI/CD
7
+ class Check < Command
8
+ def execute
9
+ unless path_exists?
10
+ puts "Error: Path does not exist: #{path_absolute}"
11
+ return 2 # Failed to check
12
+ end
13
+
14
+ threshold = @options[:threshold] || 'error'
15
+
16
+ # Skip progress output if JSON format is being used
17
+ unless @options[:json] || @options[:format] == 'json'
18
+ puts "Checking migrations: #{path_absolute}"
19
+ puts "Threshold: #{threshold.upcase}\n"
20
+ puts "(This may take a moment...)\n"
21
+ end
22
+
23
+ # Find and analyze migration files
24
+ migration_files = find_migration_files(@path)
25
+ if migration_files.empty?
26
+ puts "✅ No migrations found - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
27
+ return 0
28
+ end
29
+
30
+ puts " Found #{migration_files.length} migration files" unless @options[:json] || @options[:format] == 'json'
31
+ findings = analyze_migrations(migration_files)
32
+
33
+ if findings.empty?
34
+ puts "\n✅ No issues found - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
35
+ return 0
36
+ end
37
+
38
+ # Check if findings exceed threshold
39
+ if findings_exceed_threshold?(findings, threshold)
40
+ @formatter.print_findings(findings, "Migration Risk Check - FAILED ❌", 'check', @path)
41
+ unless @options[:json] || @options[:format] == 'json'
42
+ puts "\n🚫 Risk threshold exceeded! Deployment blocked.\n"
43
+ puts "To proceed, either:"
44
+ puts " 1. Fix the identified risks above"
45
+ puts " 2. Increase the threshold (e.g., --threshold error)"
46
+ puts " 3. Review with your DBA\n"
47
+ end
48
+ return 1 # Check failed
49
+ else
50
+ @formatter.print_findings(findings, "Migration Risk Check - PASSED ✅", 'check', @path)
51
+ puts "\n✅ All risks below threshold - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
52
+ return 0
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end