EagerEye
Catch N+1 queries in your Rails app β without running it.
Static analysis powered by Ruby AST. Fast. Zero runtime overhead. CI-ready.
English Β· TΓΌrkΓ§e
π‘ Prefer in-editor warnings? Install the VS Code extension β same engine, runs on save, surfaces issues right next to the offending line. Same speed as the CLI, just a smoother feedback loop.
Why EagerEye?
Bullet finds N+1s when your tests hit them. EagerEye finds them statically β before any code runs.
- π― Catch what tests miss β N+1s in code paths your test suite doesn't exercise still get flagged.
- β‘ Run in CI on every PR β no DB, no fixtures, no Rails boot. Just
eager_eye app/. - π¬ 11 detector types β beyond simple loop access: serializer nesting, callback queries, decorator/delegation traps, batch validation, scope chains, plucked-array misuse, and more.
- π€ Plays well with Bullet β static + runtime cover different blind spots. Use both.
Install
# Gemfile
gem "eager_eye", group: :development
bundle install
Or standalone:
gem install eager_eye
Quick Start
# Scan the default app/ directory
eager_eye
# Or scan specific paths
eager_eye app/controllers app/serializers
# Generate a config file (optional)
rails g eager_eye:install
# Run via rake
rake eager_eye:analyze
Sample output:
app/controllers/posts_controller.rb
Line 15: [LoopAssociation] Potential N+1 query: `post.author` called inside iteration
Suggestion: Use `includes(:author)` on the collection before iterating
Line 23: [MissingCounterCache] `.count` called on `comments` may cause N+1 queries
Suggestion: Add `counter_cache: true` to the belongs_to association
Total: 2 issues (2 warnings, 0 errors)
What it detects
| # | Detector | What it catches |
|---|---|---|
| 1 | LoopAssociation | Association calls inside each/map/find_each/etc. without preloading |
| 2 | SerializerNesting | Nested association access in Blueprinter / ActiveModel::Serializer / Alba blocks |
| 3 | MissingCounterCache | .count / .size on associations inside loops where a counter cache would help |
| 4 | CustomMethodQuery | .where, .find_by, .exists? etc. on association chains inside iterations |
| 5 | CountInIteration | .count (always queries) used in loops where .size (uses preload) would suffice |
| 6 | CallbackQuery | Iteration-driven queries inside ActiveRecord callbacks (after_save, after_create, ...) |
| 7 | PluckToArray | .pluck(:id) results passed to where(id: ...) instead of using a subquery; flags .all.pluck as critical |
| 8 | DelegationNPlusOne | delegate :method, to: :association calls in loops where the target isn't preloaded |
| 9 | DecoratorNPlusOne | Draper / SimpleDelegator / Presenter / ViewObject access without preload before .decorate |
| 10 | ScopeChainNPlusOne | Named scopes (.recent, .active) on associations in loops β invisible query triggers |
| 11 | ValidationNPlusOne | Model.create/save inside loops on models with validates :x, uniqueness: true |
EagerEye also tracks preloads across pagination wrappers (pagy, paginate, kaminari), per-method scope, multi-line builder chains, and helper-method parameters β so warnings respect the eager-loading you've already set up.
Detailed examples for each detector β
### 1. LoopAssociation ```ruby # Bad posts.each { |post| post.author.name } # query per post # Good β chained posts.includes(:author).each { |post| post.author.name } # Good β separate line (preload tracked across assignment) @posts = Post.includes(:author) @posts.each { |post| post.author.name } # Good β single record (no N+1 possible) @user = User.find(params[:id]) @user.posts.each { |post| post.comments } ``` Recognizes `.includes`, `.preload`, `.eager_load`, scoped `has_many` (`-> { includes(:author) }`), and pagination wrappers like `@pagy, items = pagy(...)`. ### 2. SerializerNesting ```ruby # Bad class PostSerializer < Blueprinter::Base field :author_name { |post| post.author.name } # query per serialized post end # Good β preload in controller @posts = Post.includes(:author) render json: PostSerializer.render(@posts) ``` Supports Blueprinter, ActiveModel::Serializers, Alba. ### 3. MissingCounterCache ```ruby # Bad β COUNT query for each post posts.each { |post| post.comments.count } # Good β counter cache (Comment: belongs_to :post, counter_cache: true) posts.each { |post| post.comments_count } # column read, no query ``` Only flagged inside iterations β single calls don't cause N+1. ### 4. CustomMethodQuery ```ruby # Bad β where inside loop @users.each { |user| user.teams.where(name: "Lakers").exists? } # Good β preload + filter in Ruby @users.includes(:teams).each { |user| user.teams.any? { |t| t.name == "Lakers" } } ``` Detected: `where`, `find_by`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `count`, `sum`, `average`, `minimum`, `maximum`. Per-model scoped β won't flag `obj.foo` just because some other model defines `def foo` with a query. ### 5. CountInIteration ```ruby # Bad β .count always queries, even with includes @users = User.includes(:posts) @users.each { |user| user.posts.count } # SELECT COUNT(*) per user # Good β .size uses the preload @users.each { |user| user.posts.size } ``` | Method | Loaded | Not loaded | |---|---|---| | `.count` | COUNT query | COUNT query | | `.size` | array#size | COUNT query | | `.length` | array#length | loads all then counts | ### 6. CallbackQuery ```ruby # Bad β N+1 inside callback class Order < ApplicationRecord after_create :notify_subscribers def notify_subscribers customer.followers.each { |f| f.notifications.create!(...) } # N inserts + N queries end end # Good β defer to background job after_commit :schedule_notifications, on: :create def schedule_notifications NotifySubscribersJob.perform_later(id) end ``` ### 7. PluckToArray ```ruby # Warning β two queries + memory overhead user_ids = User.active.pluck(:id) Post.where(user_id: user_ids) # Error β loads entire table user_ids = User.all.pluck(:id) Post.where(user_id: user_ids) # Good β single subquery Post.where(user_id: User.active.select(:id)) ``` `.where(...).all.pluck(:id)` is correctly recognized as scoped, not a table scan. ### 8. DelegationNPlusOne ```ruby class Order < ApplicationRecord belongs_to :user delegate :full_name, :email, to: :user end # Bad β looks like attribute access, actually loads user per order orders.each { |o| o.full_name } # Good orders.includes(:user).each { |o| o.full_name } ``` Cross-file: scans models for `delegate ... to: :assoc` declarations. ### 9. DecoratorNPlusOne ```ruby class PostDecorator < Draper::Decorator def comment_summary object.comments.map(&:body).join(", ") # query per decorated post end end # Bad @posts = Post.all.decorate # Good @posts = Post.includes(:comments).all.decorate ``` Recognizes `object`, `__getobj__`, `source`, `model` references inside Draper / SimpleDelegator / Presenter / ViewObject classes. ### 10. ScopeChainNPlusOne ```ruby class Comment < ApplicationRecord scope :recent, -> { where("created_at > ?", 1.week.ago) } end # Bad β scope call per iteration posts.each { |post| post.comments.recent } # Good β preload + filter posts.includes(:comments).each { |post| post.comments.select { |c| c.created_at > 1.week.ago } } ``` Cross-file: scans models for `scope :name, -> { ... }` declarations. ### 11. ValidationNPlusOne ```ruby class User < ApplicationRecord validates :email, uniqueness: true end # Bad β SELECT + INSERT per record params[:users].each { |p| User.create!(p) } # Good β single bulk INSERT, DB enforces uniqueness via index User.insert_all(params[:users]) ```Inline suppression
RuboCop-style comments suppress false positives or accepted patterns:
# Single line
user.posts.count # eager_eye:disable CountInIteration
# Next line
# eager_eye:disable-next-line LoopAssociation
@users.each { |u| u.profile }
# Block
# eager_eye:disable LoopAssociation, SerializerNesting
@users.each { |u| u.posts.each { |p| p. } }
# eager_eye:enable LoopAssociation, SerializerNesting
# Whole file (must be in first 5 lines)
# eager_eye:disable-file CustomMethodQuery
# With reason
user.posts.count # eager_eye:disable CountInIteration -- using counter_cache
# Disable everything
# eager_eye:disable all
Detector names are accepted as either CamelCase (LoopAssociation) or snake_case (loop_association).
Auto-fix (experimental)
eager_eye --suggest-fixes # show diff
eager_eye --fix # interactive
eager_eye --fix --force # apply all without confirmation
| Issue | Auto-fix |
|---|---|
.pluck(:id) used in .where(id: ...) |
β .select(:id) |
.count in iteration |
β .size |
Missing includes before loop |
β inserts .includes(:assoc) |
β Always review the diff and re-run your test suite after
--fix.
CI integration
# .github/workflows/eager_eye.yml
name: EagerEye
on: [pull_request]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- run: gem install eager_eye
- run: eager_eye app/ --format json > report.json
- run: |
issues=$(ruby -rjson -e 'puts JSON.parse(File.read("report.json"))["summary"]["total_issues"]')
[ "$issues" -gt 0 ] && echo "::warning::Found $issues potential N+1 issues" || true
See examples/github_action.yml for a fuller setup with PR annotations.
Baseline mode (brownfield projects)
Most existing Rails apps have hundreds of N+1 issues already β failing CI on every one of them is noise. Capture today's report as a baseline and let CI fail only on regressions (new issues introduced by a PR):
# One-time: capture the current state as the baseline
eager_eye app/ --format json > .eager_eye_baseline.json
# In CI: only NEW issues count
eager_eye app/ --baseline .eager_eye_baseline.json
The baseline file is a normal --format json report. Refresh it as you
fix existing issues. The match key is (detector, file_path, line_number,
message, severity, suggestion) β if any of those change for a known issue,
it shows up as "new" until the baseline is refreshed.
RSpec integration
# spec/rails_helper.rb
require "eager_eye/rspec"
# spec/eager_eye_spec.rb
RSpec.describe "EagerEye Analysis" do
it "controllers have no N+1 issues" do
expect("app/controllers").to pass_eager_eye
end
it "serializers are clean" do
expect("app/serializers").to pass_eager_eye(only: [:serializer_nesting])
end
# Allow some during migration
it "legacy code is acceptable" do
expect("app/services/legacy").to pass_eager_eye(max_issues: 10)
end
end
Matcher options: only: (Arrayexclude: (Arraymax_issues: (Integer, default 0).
Configuration
# .eager_eye.yml
excluded_paths:
- app/legacy/**
- lib/tasks/**
enabled_detectors: # default: all
- loop_association
- serializer_nesting
- custom_method_query
# ...
severity_levels:
loop_association: error
missing_counter_cache: info
# ...
min_severity: warning # info | warning | error
app_path: app
fail_on_issues: true
Or programmatically:
EagerEye.configure do |config|
config.excluded_paths = ["app/legacy/**"]
config.enabled_detectors = [:loop_association, :serializer_nesting]
config.min_severity = :warning
config.fail_on_issues = true
end
CLI reference
Usage: eager_eye [paths] [options]
-f, --format FORMAT console | json (default: console)
-e, --exclude PATTERN glob to exclude (repeatable)
-o, --only DETECTORS comma-separated detector list
-s, --min-severity LEVEL info | warning | error
--no-fail always exit 0
--no-color plain output
--baseline FILE compare against a previous JSON report;
only NEW issues are reported (and counted)
--suggest-fixes print fix diffs without applying
--fix interactively apply auto-fixes
--fix --force apply all auto-fixes
-v, --version
-h, --help
Limitations
EagerEye is static analysis. That comes with trade-offs:
- No runtime context β can't see what
find_eachblock actually does at runtime. - Heuristic association detection β falls back to common name patterns (
author,user, ...) when a model isn't in the parsed set; can over-flag in tiny edge cases. - Cross-file flow β propagates preloads across same-class methods (controller β its private helpers), but cross-file flow (controller β external service object β iteration) isn't tracked yet.
- Ruby code only β doesn't read SQL or your DB schema.
Use it alongside Bullet for a complete picture: static (EagerEye) catches code paths tests don't hit, runtime (Bullet) catches what static can't see.
Development
bin/setup
bundle exec rspec
bundle exec rubocop
bin/console
Contributing
Bug reports and PRs welcome at https://github.com/hamzagedikkaya/eager_eye.
- Fork
git checkout -b feature/my-feature- Add specs (this repo is at ~95% coverage)
git commit -am 'Add my feature'- Open a Pull Request
License
MIT β see LICENSE.txt.
Code of Conduct
Everyone interacting in EagerEye's codebases, issue trackers, and discussions is expected to follow the code of conduct.