EagerEye
Rails uygulamandaki N+1 sorgularını yakala — uygulamayı çalıştırmadan.
Ruby AST tabanlı statik analiz. Hızlı. Sıfır runtime maliyeti. CI'a hazır.
English · Türkçe
💡 Editör içinde uyarı görmeyi mi tercih edersin? VS Code eklentisini kur — aynı motor, kaydettiğinde çalışır, sorunları doğrudan ilgili satırın yanında gösterir. CLI ile aynı hızda, sadece daha akıcı bir geri bildirim döngüsü.
Neden EagerEye?
Bullet N+1'leri test'lerin onlara denk geldiğinde bulur. EagerEye ise statik olarak bulur — kod hiç çalışmadan.
- 🎯 Test'lerin kaçırdıklarını yakala — Test suite'in girmediği kod yollarındaki N+1'ler de işaretlenir.
- ⚡ Her PR'da CI'da çalıştır — DB yok, fixture yok, Rails boot yok. Sadece
eager_eye app/. - 🔬 11 detector tipi — basit loop erişiminin ötesinde: serializer nesting, callback query'leri, decorator/delegation tuzakları, batch validation, scope chain'leri, plucked-array yanlış kullanımı ve dahası.
- 🤝 Bullet ile iyi anlaşır — statik + runtime farklı kör noktaları kapatır. İkisini birden kullan.
Kurulum
# Gemfile
gem "eager_eye", group: :development
bundle install
Veya bağımsız:
gem install eager_eye
Hızlı Başlangıç
# Varsayılan app/ dizinini tara
eager_eye
# Veya belirli yolları tara
eager_eye app/controllers app/serializers
# Config dosyası oluştur (opsiyonel)
rails g eager_eye:install
# Rake ile çalıştır
rake eager_eye:analyze
Örnek çıktı:
app/controllers/posts_controller.rb
Line 15: [LoopAssociation] Olası N+1 sorgu: `post.author` iterasyon içinde çağrılıyor
Öneri: Iterasyondan önce koleksiyona `includes(:author)` ekle
Line 23: [MissingCounterCache] `comments` üzerinde `.count` çağrısı N+1'e yol açabilir
Öneri: belongs_to ilişkisine `counter_cache: true` ekle
Total: 2 issues (2 warnings, 0 errors)
Neyi tespit eder
| # | Detector | Neyi yakalar |
|---|---|---|
| 1 | LoopAssociation | each/map/find_each vb. içinde preload edilmemiş ilişki çağrıları |
| 2 | SerializerNesting | Blueprinter / ActiveModel::Serializer / Alba block'larında nested ilişki erişimi |
| 3 | MissingCounterCache | Loop içinde counter cache'le çözülebilecek .count / .size çağrıları |
| 4 | CustomMethodQuery | Iterasyon içinde ilişki zincirinde .where, .find_by, .exists? vb. |
| 5 | CountInIteration | .size (preload kullanır) yeterken loop'ta .count (her zaman query) kullanımı |
| 6 | CallbackQuery | ActiveRecord callback'leri içinde iterasyon kaynaklı sorgular (after_save, after_create, ...) |
| 7 | PluckToArray | .pluck(:id) sonucunun subquery yerine where(id: ...)'a verilmesi; .all.pluck kritik olarak işaretlenir |
| 8 | DelegationNPlusOne | Loop içinde delegate :method, to: :association çağrıları, hedef preload edilmemişse |
| 9 | DecoratorNPlusOne | Draper / SimpleDelegator / Presenter / ViewObject erişimi, .decorate öncesi preload yoksa |
| 10 | ScopeChainNPlusOne | Loop içinde ilişki üzerine isimli scope'lar (.recent, .active) — görünmez query tetikleyicileri |
| 11 | ValidationNPlusOne | validates :x, uniqueness: true olan modellerde loop içinde Model.create/save |
EagerEye preload'ları sayfalama wrapper'ları (pagy, paginate, kaminari), per-method scope, çok satırlı builder zincirleri ve helper-method parametreleri arasında da takip eder — yani önceden ayarladığın eager-loading'lere saygı gösterir.
Her detector için detaylı örnekler →
### 1. LoopAssociation ```ruby # Kötü posts.each { |post| post.author.name } # her post için bir query # İyi — zincirli posts.includes(:author).each { |post| post.author.name } # İyi — ayrı satır (preload atama üzerinden takip ediliyor) @posts = Post.includes(:author) @posts.each { |post| post.author.name } # İyi — tek kayıt (N+1 mümkün değil) @user = User.find(params[:id]) @user.posts.each { |post| post.comments } ``` `.includes`, `.preload`, `.eager_load`, scope'lu `has_many` (`-> { includes(:author) }`) ve `@pagy, items = pagy(...)` gibi sayfalama wrapper'larını tanır. ### 2. SerializerNesting ```ruby # Kötü class PostSerializer < Blueprinter::Base field :author_name { |post| post.author.name } # her serialize edilen post için query end # İyi — controller'da preload @posts = Post.includes(:author) render json: PostSerializer.render(@posts) ``` Blueprinter, ActiveModel::Serializers ve Alba'yı destekler. ### 3. MissingCounterCache ```ruby # Kötü — her post için COUNT sorgusu posts.each { |post| post.comments.count } # İyi — counter cache (Comment: belongs_to :post, counter_cache: true) posts.each { |post| post.comments_count } # kolon okuma, query yok ``` Sadece iterasyon içinde flag'lenir — tek seferlik çağrılar N+1 oluşturmaz. ### 4. CustomMethodQuery ```ruby # Kötü — loop içinde where @users.each { |user| user.teams.where(name: "Lakers").exists? } # İyi — preload + Ruby'de filtreleme @users.includes(:teams).each { |user| user.teams.any? { |t| t.name == "Lakers" } } ``` Tespit edilen: `where`, `find_by`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `count`, `sum`, `average`, `minimum`, `maximum`. Per-model scope'lu — başka bir model'de `def foo` query metodu var diye `obj.foo`'yu flag'lemez. ### 5. CountInIteration ```ruby # Kötü — .count includes olsa bile her zaman query atar @users = User.includes(:posts) @users.each { |user| user.posts.count } # her user için SELECT COUNT(*) # İyi — .size preload'u kullanır @users.each { |user| user.posts.size } ``` | Metod | Yüklenmiş | Yüklenmemiş | |---|---|---| | `.count` | COUNT sorgusu | COUNT sorgusu | | `.size` | array#size | COUNT sorgusu | | `.length` | array#length | hepsini yükler sonra sayar | ### 6. CallbackQuery ```ruby # Kötü — callback içinde N+1 class Order < ApplicationRecord after_create :notify_subscribers def notify_subscribers customer.followers.each { |f| f.notifications.create!(...) } # N insert + N query end end # İyi — background job'a devret after_commit :schedule_notifications, on: :create def schedule_notifications NotifySubscribersJob.perform_later(id) end ``` ### 7. PluckToArray ```ruby # Uyarı — iki sorgu + bellek maliyeti user_ids = User.active.pluck(:id) Post.where(user_id: user_ids) # Hata — tüm tabloyu yükler user_ids = User.all.pluck(:id) Post.where(user_id: user_ids) # İyi — tek subquery Post.where(user_id: User.active.select(:id)) ``` `.where(...).all.pluck(:id)` doğru şekilde scope'lu olarak tanınır, table scan olarak değil. ### 8. DelegationNPlusOne ```ruby class Order < ApplicationRecord belongs_to :user delegate :full_name, :email, to: :user end # Kötü — attribute erişimi gibi görünür ama her order için user yükler orders.each { |o| o.full_name } # İyi orders.includes(:user).each { |o| o.full_name } ``` Cross-file: model dosyalarını `delegate ... to: :assoc` deklarasyonları için tarar. ### 9. DecoratorNPlusOne ```ruby class PostDecorator < Draper::Decorator def comment_summary object.comments.map(&:body).join(", ") # her decorate edilen post için query end end # Kötü @posts = Post.all.decorate # İyi @posts = Post.includes(:comments).all.decorate ``` Draper / SimpleDelegator / Presenter / ViewObject sınıfları içinde `object`, `__getobj__`, `source`, `model` referanslarını tanır. ### 10. ScopeChainNPlusOne ```ruby class Comment < ApplicationRecord scope :recent, -> { where("created_at > ?", 1.week.ago) } end # Kötü — her iterasyonda scope çağrısı posts.each { |post| post.comments.recent } # İyi — preload + filtreleme posts.includes(:comments).each { |post| post.comments.select { |c| c.created_at > 1.week.ago } } ``` Cross-file: model dosyalarını `scope :name, -> { ... }` deklarasyonları için tarar. ### 11. ValidationNPlusOne ```ruby class User < ApplicationRecord validates :email, uniqueness: true end # Kötü — her kayıt için SELECT + INSERT params[:users].each { |p| User.create!(p) } # İyi — tek bulk INSERT, DB unique index ile uniqueness'i sağlar User.insert_all(params[:users]) ```Inline suppression
False positive'leri veya bilinçli desenleri RuboCop tarzı yorumlarla bastır:
# Tek satır
user.posts.count # eager_eye:disable CountInIteration
# Sonraki satır
# 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
# Tüm dosya (ilk 5 satırda olmalı)
# eager_eye:disable-file CustomMethodQuery
# Sebep ile
user.posts.count # eager_eye:disable CountInIteration -- counter_cache kullanılıyor
# Hepsini kapat
# eager_eye:disable all
Detector isimleri hem CamelCase (LoopAssociation) hem snake_case (loop_association) olarak kabul edilir.
Auto-fix (deneysel)
eager_eye --suggest-fixes # diff'i göster
eager_eye --fix # interaktif uygula
eager_eye --fix --force # onay sormadan hepsini uygula
| Sorun | Otomatik düzeltme |
|---|---|
.where(id: ...) içinde .pluck(:id) |
→ .select(:id) |
Iterasyon içinde .count |
→ .size |
Loop öncesi eksik includes |
→ .includes(:assoc) ekler |
⚠
--fixsonrası diff'i mutlaka gözden geçir ve testlerini tekrar çalıştır.
CI entegrasyonu
# .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::$issues olası N+1 sorunu bulundu" || true
PR annotation'lı tam örnek için bkz. examples/github_action.yml.
Baseline modu (brownfield projeler)
Çoğu mevcut Rails uygulamasında zaten yüzlerce N+1 sorunu var — her birinde CI'yi düşürmek anlamlı değil. Bugünkü raporu baseline olarak yakalayıp CI'nin sadece regresyonlarda (PR'ın eklediği yeni issue'larda) fail etmesini sağlayabilirsiniz:
# Tek seferlik: mevcut durumu baseline olarak yakala
eager_eye app/ --format json > .eager_eye_baseline.json
# CI'da: yalnızca YENİ issue'lar sayılır
eager_eye app/ --baseline .eager_eye_baseline.json
Baseline dosyası standart --format json raporudur. Mevcut issue'ları
düzelttikçe baseline'ı yenileyin. Eşleşme anahtarı: (detector, file_path,
line_number, message, severity, suggestion) — bilinen bir issue'da bu
alanlardan biri değişirse baseline yenilenene kadar "yeni" olarak görünür.
RSpec entegrasyonu
# spec/rails_helper.rb
require "eager_eye/rspec"
# spec/eager_eye_spec.rb
RSpec.describe "EagerEye Analizi" do
it "controller'larda N+1 yok" do
expect("app/controllers").to pass_eager_eye
end
it "serializer'lar temiz" do
expect("app/serializers").to pass_eager_eye(only: [:serializer_nesting])
end
# Migration sırasında bir miktar tolere et
it "legacy kod kabul edilebilir" do
expect("app/services/legacy").to pass_eager_eye(max_issues: 10)
end
end
Matcher seçenekleri: only: (Arrayexclude: (Arraymax_issues: (Integer, varsayılan 0).
Yapılandırma
# .eager_eye.yml
excluded_paths:
- app/legacy/**
- lib/tasks/**
enabled_detectors: # varsayılan: hepsi
- 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
Veya programatik olarak:
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 referansı
Kullanım: eager_eye [yollar] [seçenekler]
-f, --format FORMAT console | json (varsayılan: console)
-e, --exclude PATTERN hariç tutulacak glob (tekrarlanabilir)
-o, --only DETECTORS virgülle ayrılmış detector listesi
-s, --min-severity LEVEL info | warning | error
--no-fail her zaman 0 ile çık
--no-color düz çıktı
--baseline FILE önceki bir JSON raporuyla karşılaştır;
sadece YENİ issue'lar raporlanır (ve sayılır)
--suggest-fixes fix diff'lerini uygulamadan göster
--fix interaktif olarak auto-fix uygula
--fix --force tüm auto-fix'leri uygula
-v, --version
-h, --help
Limitasyonlar
EagerEye statik analiz yapar. Bunun trade-off'ları var:
- Runtime context yok —
find_eachblock'unun runtime'da gerçekten ne yaptığını göremez. - Heuristic ilişki tespiti — model parse setinde olmadığında yaygın isim desenlerine (
author,user, ...) düşer; küçük edge case'lerde fazla flag'leyebilir. - Cross-file akış — preload'ları aynı sınıftaki metodlar arasında takip eder (controller → kendi private helper'ları), ama cross-file akış (controller → harici service object → iterasyon) henüz takip edilmiyor.
- Sadece Ruby kodu — SQL veya DB şemanı okumaz.
Tam kapsama için Bullet ile birlikte kullan: statik (EagerEye) test'lerin girmediği yolları, runtime (Bullet) statik analizin göremediklerini yakalar.
Geliştirme
bin/setup
bundle exec rspec
bundle exec rubocop
bin/console
Katkı
Bug raporları ve PR'lar için: https://github.com/hamzagedikkaya/eager_eye.
- Fork'la
git checkout -b feature/yeni-ozellik- Spec ekle (bu repo ~%95 coverage'da)
git commit -am 'yeni özellik ekle'- Pull Request aç
Lisans
MIT — bkz. LICENSE.txt.
Davranış Kuralları
EagerEye'ın codebase'inde, issue tracker'larında ve tartışmalarında etkileşime giren herkesin davranış kurallarına uyması beklenir.