From a1118e05ac627ff6f796fd35839c05cb774ae814 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 16:52:08 +0200 Subject: [PATCH] test: cover reset_counts! and index_hits --- test/test_orm_index.rb | 158 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index e236fda..d72c434 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -479,3 +479,161 @@ class CountingIndexStore < Prism::Orm::Store result end + def reset_counts! + @scan_count = 0 + @index_hits = 0 + @range_hits = 0 + @in_hits = 0 + @eq_range_hits = 0 + end + + def index_hits + @index_hits + end +end + +class OrmIndexRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title status]) + @store.add_index(:posts, :status) + + post_class = Class.new(Prism::Orm::Model) { table_name :posts } + post_class.store = @store + Object.const_set(:IndexedPost, post_class) + + IndexedPost.create!(title: 'a', status: 'draft') + IndexedPost.create!(title: 'b', status: 'published') + IndexedPost.create!(title: 'c', status: 'draft') + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :IndexedPost) if Object.const_defined?(:IndexedPost) + end + + def test_where_on_an_indexed_column_never_scans_the_full_table + @store.reset_counts! + results = IndexedPost.where(status: 'draft').to_a + assert_equal ['a', 'c'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.index_hits + end + + def test_where_on_an_unindexed_column_falls_back_to_a_full_scan + @store.reset_counts! + results = IndexedPost.where(title: 'a').to_a + assert_equal ['a'], results.map { |post| post['title'] } + assert_equal 1, @store.scan_count + assert_equal 0, @store.index_hits + end + + def test_chained_where_mixing_indexed_and_unindexed_columns_is_still_correct + @store.reset_counts! + results = IndexedPost.where(status: 'draft').where(title: 'c').to_a + assert_equal ['c'], results.map { |post| post['title'] } + assert_equal 0, @store.scan_count, 'the indexed equality should still narrow the candidate set' + end + + def test_or_condition_cannot_use_the_index_but_is_still_correct + @store.reset_counts! + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'b')).to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 1, @store.scan_count, 'an OR across columns cannot guarantee a single index key, so it must scan' + end + + def test_find_by_uses_the_index + @store.reset_counts! + post = IndexedPost.find_by(status: 'published') + assert_equal 'b', post['title'] + assert_equal 0, @store.scan_count + end + + def test_or_condition_unions_index_hits_once_both_branches_are_indexed + @store.add_index(:posts, :title) + @store.reset_counts! + + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'b')).to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count, 'both branches are indexed, so the union must never fall back to a scan' + assert_equal 2, @store.index_hits, 'one lookup per branch of the Or' + end + + def test_or_condition_dedupes_a_row_matched_by_both_indexed_branches + @store.add_index(:posts, :title) + @store.reset_counts! + + # post "a" is status draft AND title "a" - both branches of the Or match + # the same row, so the union must not return it twice. + results = IndexedPost.where(status: 'draft').or(IndexedPost.where(title: 'a')).to_a + assert_equal ['a', 'c'], results.map { |post| post['title'] }.sort + assert_equal 2, results.size + assert_equal 0, @store.scan_count + end + + # `a.or(b).or(c)` builds OrNode(OrNode(a, b), c) - a left-nested tree, not + # a flat three-way union. OrNode#indexed_candidates only recurses into + # @left/@right, so this exercises that the recursion actually reaches the + # innermost branch rather than treating the outer node's @left (itself an + # OrNode) as an opaque leaf. Left untested since the index-union Or was + # first added, per prism-postep.md "kierunki dalsze". + def test_three_way_or_uses_the_index_on_every_branch + @store.add_index(:posts, :title) + IndexedPost.create!(title: 'd', status: 'published') + @store.reset_counts! + + results = IndexedPost.where(status: 'draft') + .or(IndexedPost.where(title: 'b')) + .or(IndexedPost.where(title: 'd')) + .to_a + assert_equal ['a', 'b', 'c', 'd'], results.map { |post| post['title'] }.sort + assert_equal 0, @store.scan_count, 'every branch of the three-way Or is indexed, so it must never fall back to a scan' + assert_equal 3, @store.index_hits, 'one lookup per branch, including the nested inner Or' + end + + def test_three_way_or_falls_back_to_a_scan_when_one_branch_is_unindexed + @store.reset_counts! + + # title carries no index in this test (unlike the one above), so the + # whole union must scan rather than silently dropping that branch's + # matches while still answering the other two from their indexes. + results = IndexedPost.where(status: 'draft') + .or(IndexedPost.where(title: 'b')) + .or(IndexedPost.where(status: 'archived')) + .to_a + assert_equal ['a', 'b', 'c'], results.map { |post| post['title'] }.sort + assert_equal 1, @store.scan_count + end +end + +class OrmIndexRangeRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-range-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:products, %w[id name price]) + @store.add_index(:products, :price) + + product_class = Class.new(Prism::Orm::Model) { table_name :products } + product_class.store = @store + Object.const_set(:RangeIndexedProduct, product_class) + + RangeIndexedProduct.create!(name: 'cheap', price: 5) + RangeIndexedProduct.create!(name: 'mid', price: 15) + RangeIndexedProduct.create!(name: 'pricey', price: 25) + RangeIndexedProduct.create!(name: 'luxury', price: 35) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :RangeIndexedProduct) if Object.const_defined?(:RangeIndexedProduct) + end + + def test_gte_on_an_indexed_column_never_scans_the_full_table + @store.reset_counts! + results = RangeIndexedProduct.where(price: { gte: 15 }).order(:price).to_a + assert_equal %w[mid pricey luxury], results.map { |p| p['name'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.range_hits + end +