Skip to content

Commit ac2c9e3

Browse files
committed
add prosopite
1 parent b5437a0 commit ac2c9e3

File tree

8 files changed

+112
-0
lines changed

8 files changed

+112
-0
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ gem "wicked" # Multi-step form wizard for Rails
6464
group :development, :test do
6565
gem "brakeman" # Security inspection
6666
gem "bullet" # Detect and fix N+1 queries
67+
gem "prosopite" # N+1 query detection via SQL pattern analysis
6768
gem "byebug", platforms: %i[mri mingw x64_mingw] # Debugger console
6869
gem "dotenv-rails" # Environment variable management
6970
gem "erb_lint", require: false # ERB linter

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ GEM
424424
actionpack (>= 7.1)
425425
prettyprint (0.2.0)
426426
prism (1.9.0)
427+
prosopite (2.1.2)
427428
pry (0.16.0)
428429
coderay (~> 1.1)
429430
method_source (~> 1.0)
@@ -757,6 +758,7 @@ DEPENDENCIES
757758
pg_query
758759
pghero
759760
pretender
761+
prosopite
760762
pry
761763
pry-byebug
762764
puma (~> 7.0)

config/environments/development.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@
7575
Bullet.bullet_logger = true
7676
end
7777

78+
# Prosopite N+1 query detection
79+
config.prosopite_enabled = true
80+
config.prosopite_min_n_queries = 5 # More lenient for development
81+
7882
# Annotate rendered view with file names.
7983
config.action_view.annotate_rendered_view_with_filenames = true
8084

config/environments/production.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,8 @@
108108
# ]
109109
# Skip DNS rebinding protection for the default health check endpoint.
110110
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
111+
112+
# Prosopite N+1 query detection (disabled by default in production)
113+
config.prosopite_enabled = ENV.fetch("PROSOPITE_ENABLED", "false") == "true"
114+
config.prosopite_min_n_queries = ENV.fetch("PROSOPITE_MIN_N_QUERIES", "10").to_i
111115
end

config/environments/test.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
# Bullet.raise = true # TODO https://github.com/rubyforgood/casa/issues/2441
7272
end
7373

74+
# Prosopite N+1 query detection
75+
config.prosopite_enabled = true
76+
config.prosopite_min_n_queries = 2 # Stricter for tests
77+
7478
# Annotate rendered view with file names.
7579
# config.action_view.annotate_rendered_view_with_filenames = true
7680

config/initializers/prosopite.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
# Only enable Rack middleware if Prosopite is configured on
4+
if Rails.configuration.respond_to?(:prosopite_enabled) && Rails.configuration.prosopite_enabled
5+
require "prosopite/middleware/rack"
6+
Rails.configuration.middleware.use(Prosopite::Middleware::Rack)
7+
end
8+
9+
Rails.application.config.after_initialize do
10+
# Core settings
11+
Prosopite.enabled = Rails.configuration.respond_to?(:prosopite_enabled) &&
12+
Rails.configuration.prosopite_enabled
13+
14+
# Minimum repeated queries to trigger detection (default: 2)
15+
Prosopite.min_n_queries = Rails.configuration.respond_to?(:prosopite_min_n_queries) ?
16+
Rails.configuration.prosopite_min_n_queries : 2
17+
18+
# Logging options
19+
Prosopite.rails_logger = true # Log to Rails.logger
20+
Prosopite.prosopite_logger = Rails.env.development? # Log to log/prosopite.log
21+
end

spec/.prosopite_ignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Directories to exclude from Prosopite N+1 scanning
2+
# Remove paths as you fix N+1s in each area
3+
#
4+
# Example entries:
5+
# spec/features
6+
# spec/system
7+
# spec/requests/legacy

spec/support/prosopite.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
return unless defined?(Prosopite)
4+
5+
# Test configuration
6+
Prosopite.enabled = true
7+
Prosopite.raise = true # Fail specs on N+1 detection
8+
Prosopite.rails_logger = true
9+
Prosopite.prosopite_logger = true
10+
11+
# Allowlist for known acceptable N+1 patterns (e.g., test matchers)
12+
Prosopite.allow_stack_paths = [
13+
"shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb",
14+
"shoulda/matchers/active_model/validate_presence_of_matcher.rb",
15+
"shoulda/matchers/active_model/validate_inclusion_of_matcher.rb",
16+
"shoulda/matchers/active_model/allow_value_matcher.rb"
17+
]
18+
19+
# Optional: Load ignore list from file for gradual rollout
20+
PROSOPITE_IGNORE = if File.exist?("spec/.prosopite_ignore")
21+
File.read("spec/.prosopite_ignore").lines.map(&:chomp).reject(&:empty?)
22+
else
23+
[]
24+
end
25+
26+
# Monkey-patch FactoryBot to pause during factory creation
27+
# Prevents false positives from factory callbacks
28+
if defined?(FactoryBot)
29+
module FactoryBot
30+
module Strategy
31+
class Create
32+
alias_method :original_result, :result
33+
34+
def result(evaluation)
35+
if defined?(Prosopite) && Prosopite.enabled?
36+
Prosopite.pause { original_result(evaluation) }
37+
else
38+
original_result(evaluation)
39+
end
40+
end
41+
end
42+
end
43+
end
44+
end
45+
46+
# RSpec integration
47+
RSpec.configure do |config|
48+
config.around do |example|
49+
if use_prosopite?(example)
50+
Prosopite.scan { example.run }
51+
else
52+
original_enabled = Prosopite.enabled?
53+
Prosopite.enabled = false
54+
example.run
55+
Prosopite.enabled = original_enabled
56+
end
57+
end
58+
end
59+
60+
def use_prosopite?(example)
61+
# Explicit metadata takes precedence
62+
return false if example.metadata[:disable_prosopite]
63+
return true if example.metadata[:enable_prosopite]
64+
65+
# Check against ignore list
66+
PROSOPITE_IGNORE.none? do |path|
67+
File.fnmatch?("./#{path}/*", example.metadata[:rerun_file_path].to_s)
68+
end
69+
end

0 commit comments

Comments
 (0)