Project

eager_eye

0.0
The project is in a healthy, maintained state
EagerEye detects N+1 query problems using AST analysis without running your code.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 2.4
~> 3.3
 Project Readme

EagerEye

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

CI Gem Version Downloads Ruby License VS Code Extension

πŸ’‘ 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

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

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

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

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.author } }
# 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: (Array), exclude: (Array globs), max_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_each block 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.

  1. Fork
  2. git checkout -b feature/my-feature
  3. Add specs (this repo is at ~95% coverage)
  4. git commit -am 'Add my feature'
  5. 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.