From b8cca00f063e037b8124cba49fe7147a690da031 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 20:17:53 +0200 Subject: [PATCH] test: cover OrmNPlusOneDetectorTest and teardown --- test/test_orm_n_plus_one_detector.rb | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_orm_n_plus_one_detector.rb diff --git a/test/test_orm_n_plus_one_detector.rb b/test/test_orm_n_plus_one_detector.rb new file mode 100644 index 0000000..d088c41 --- /dev/null +++ b/test/test_orm_n_plus_one_detector.rb @@ -0,0 +1,161 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'stringio' + +class OrmNPlusOneDetectorTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-n-plus-one-store') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + @store.create_table(:bios, %w[id author_id headline]) + + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id' + has_one :bio, class_name: 'Bio', foreign_key: 'author_id' + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + bio_class = Class.new(Prism::Orm::Model) do + table_name :bios + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [author_class, post_class, bio_class].each { |klass| klass.store = @store } + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + Object.const_set(:Bio, bio_class) + + @io = StringIO.new + end + + def teardown + Prism::Orm::NPlusOneDetector.disable! + Prism::Orm::NPlusOneDetector.reset! + FileUtils.remove_entry(@dir) + %i[Author Post Bio].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def create_authors_with_posts(count) + count.times do |i| + author = Author.create!(name: "author#{i}") + Post.create!(title: "post#{i}", author_id: author.id) + author + end + end + + def test_disabled_by_default_never_warns_even_on_an_obvious_n_plus_one + create_authors_with_posts(5) + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + assert_empty @io.string + end + + def test_below_threshold_does_not_warn + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string + end + + def test_reaching_threshold_across_distinct_owners_warns + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(3) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_match(/N\+1 query detected: Author#posts lazily loaded for 3 separate Author records/, @io.string) + assert_match(/\.includes\(:posts\)/, @io.string) + end + + def test_preloaded_via_includes_never_counts_as_a_lazy_load + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(3) + + Author.all.includes(:posts).to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string + end + + def test_repeated_reads_on_the_same_owner_do_not_inflate_the_count + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + author = Author.first + + 5.times { author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string, 'one owner read five times is still one owner, not five' + end + + def test_two_different_associations_are_tracked_and_reported_independently + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + 3.times do |i| + author = Author.create!(name: "author#{i}") + Post.create!(title: "post#{i}", author_id: author.id) + Bio.create!(headline: "bio#{i}", author_id: author.id) + end + + Author.all.to_a.each do |author| + author.posts.to_a + author.bio + end + Prism::Orm::NPlusOneDetector.report! + + assert_match(/Author#posts lazily loaded for 3/, @io.string) + assert_match(/Author#bio lazily loaded for 3/, @io.string) + end + + def test_belongs_to_lazy_loads_across_distinct_children_are_detected_too + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + author = Author.create!(name: 'shared') + 3.times { |i| Post.create!(title: "post#{i}", author_id: author.id) } + + Post.all.to_a.each(&:author) + Prism::Orm::NPlusOneDetector.report! + + assert_match(/Post#author lazily loaded for 3 separate Post records/, @io.string) + end + + def test_raise_on_violation_raises_instead_of_only_logging + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io, raise_on_violation: true) + create_authors_with_posts(2) + + error = assert_raises(Prism::Orm::NPlusOneViolation) do + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report! + end + assert_match(/Author#posts/, error.message) + end + + def test_reset_clears_the_tracker_between_requests + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(1) + author = Author.first + + author.posts.to_a + Prism::Orm::NPlusOneDetector.reset! + author.posts.to_a + Prism::Orm::NPlusOneDetector.report! + + assert_empty @io.string, 'reset! between requests must not let counts carry over' + end + + def test_context_is_included_in_the_warning_line + Prism::Orm::NPlusOneDetector.enable!(threshold: 2, io: @io) + create_authors_with_posts(2) + + Author.all.to_a.each { |author| author.posts.to_a } + Prism::Orm::NPlusOneDetector.report!(method: 'GET', path: '/authors') + + assert_match(%r{method=\"GET\" path=\"/authors\"}, @io.string) + end +end