diff --git a/test/test_orm_index.rb b/test/test_orm_index.rb index 94704c8..c00380d 100644 --- a/test/test_orm_index.rb +++ b/test/test_orm_index.rb @@ -794,3 +794,191 @@ class OrmIndexEqualityRangeStoreTest < Minitest::Test wide.insert(:tasks, project: 'a', priority: 'high', position: 1) wide.insert(:tasks, project: 'a', priority: 'high', position: 2) + declared_order = wide.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + reversed_order = wide.lookup_by_index_equality_range(:tasks, { priority: 'high', project: 'a' }, :position, gte: 0) + assert_equal [1, 2], declared_order.map { |row| row['position'] }.sort + assert_equal declared_order.map { |row| row['id'] }.sort, reversed_order.map { |row| row['id'] }.sort + end + + def test_backfills_from_existing_rows + @store.insert(:posts, featured: true, position: 1) + @store.insert(:posts, featured: true, position: 2) + @store.add_index(:posts, %i[featured position]) + + matches = @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 2) + assert_equal [2], matches.map { |row| row['position'] } + end + + def test_tracks_inserts_updates_and_deletes + @store.add_index(:posts, %i[featured position]) + a = @store.insert(:posts, featured: true, position: 1) + @store.insert(:posts, featured: true, position: 2) + + assert_equal [1, 2], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] }.sort + + @store.update(:posts, a, position: 9) + assert_equal [2, 9], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] }.sort + + @store.delete(:posts, a) + assert_equal [2], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, + gte: 0).map { |r| r['position'] } + end + + def test_switching_the_prefix_value_moves_a_row_to_the_other_partition + @store.add_index(:posts, %i[featured position]) + id = @store.insert(:posts, featured: true, position: 1) + + @store.update(:posts, id, featured: false) + assert_equal [], @store.lookup_by_index_equality_range(:posts, { 'featured' => true }, :position, gte: 0) + assert_equal [1], @store.lookup_by_index_equality_range(:posts, { 'featured' => false }, :position, + gte: 0).map { |r| r['position'] } + end + + def test_an_unknown_prefix_value_matches_nothing_without_a_scan + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1) + + assert_equal [], @store.lookup_by_index_equality_range(:posts, { 'featured' => 'nonexistent' }, :position, gte: 0) + end + + def test_a_three_column_composite_index_still_works_with_a_two_column_prefix + @store = Prism::Orm::Store.new(File.join(@dir, 'wide.pstore')) + @store.create_table(:tasks, %w[id project priority position]) + @store.add_index(:tasks, %i[project priority position]) + @store.insert(:tasks, project: 'a', priority: 'high', position: 1) + @store.insert(:tasks, project: 'a', priority: 'high', position: 2) + @store.insert(:tasks, project: 'a', priority: 'low', position: 1) + + matches = @store.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + assert_equal [1, 2], matches.map { |row| row['position'] }.sort + end + + def test_lookup_by_index_range_still_refuses_a_composite_index_unchanged_behavior + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1) + assert_nil @store.lookup_by_index_range(:posts, :position, gte: 0) + end + + # A paranoid model's Model.all already carries `where(deleted_at: nil)` + # before any caller-added `.where(featured: ...)` - real regression this + # session's blog_demo.rb run caught: the index prefix is only `featured`, + # so `equalities` legitimately arrives with an EXTRA column + # (`deleted_at`) the index knows nothing about. That must still resolve + # through the index (using only the subset it actually covers), not fall + # back to a scan just because the caller happened to also constrain an + # unrelated column. + def test_an_equality_column_outside_the_index_prefix_does_not_block_the_lookup + @store.add_index(:posts, %i[featured position]) + @store.insert(:posts, featured: true, position: 1, deleted_at: nil) + @store.insert(:posts, featured: true, position: 5, deleted_at: nil) + + matches = @store.lookup_by_index_equality_range(:posts, { 'deleted_at' => nil, 'featured' => true }, + :position, lt: 3) + assert_equal [1], matches.map { |row| row['position'] } + end + + def test_the_most_specific_covering_composite_index_wins_when_several_could_answer + wide = Prism::Orm::Store.new(File.join(@dir, 'multi.pstore')) + wide.create_table(:tasks, %w[id project priority position]) + wide.add_index(:tasks, %i[project position]) + wide.add_index(:tasks, %i[project priority position]) + wide.insert(:tasks, project: 'a', priority: 'high', position: 1) + wide.insert(:tasks, project: 'a', priority: 'low', position: 2) + + matches = wide.lookup_by_index_equality_range(:tasks, { project: 'a', priority: 'high' }, :position, gte: 0) + assert_equal [1], matches.map { |row| row['position'] }, + 'the 3-column index covering priority too should be preferred over the 2-column one' + end +end + +class OrmIndexEqualityRangeRelationTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-index-eq-range-relation') + @store = CountingIndexStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id featured position]) + @store.add_index(:posts, %i[featured position]) + + post_class = Class.new(Prism::Orm::Model) { table_name :posts } + post_class.store = @store + Object.const_set(:EqRangeIndexedPost, post_class) + + EqRangeIndexedPost.create!(featured: true, position: 1) + EqRangeIndexedPost.create!(featured: true, position: 2) + EqRangeIndexedPost.create!(featured: true, position: 3) + EqRangeIndexedPost.create!(featured: false, position: 1) + EqRangeIndexedPost.create!(featured: false, position: 2) + end + + def teardown + FileUtils.remove_entry(@dir) + Object.send(:remove_const, :EqRangeIndexedPost) if Object.const_defined?(:EqRangeIndexedPost) + end + + def test_two_chained_wheres_scope_equality_then_range_never_scan + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).where(position: { lt: 3 }).order(:position).to_a + assert_equal [1, 2], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + assert_equal 1, @store.eq_range_hits + end + + def test_single_where_with_both_conditions_in_one_hash_never_scans + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true, position: { gte: 2 }).to_a + assert_equal [2, 3], results.map { |p| p['position'] }.sort + assert_equal 0, @store.scan_count + assert_equal 1, @store.eq_range_hits + end + + def test_a_ruby_range_value_alongside_an_equality_uses_the_composite_index + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: false).where(position: 1..1).to_a + assert_equal [1], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + end + + def test_the_other_scope_partition_is_unaffected_by_the_first + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: false).where(position: { gte: 0 }).to_a + assert_equal [1, 2], results.map { |p| p['position'] }.sort + assert_equal 0, @store.scan_count + end + + def test_range_without_any_equality_falls_back_to_a_scan_not_the_composite_index + # This fixture only has the composite [featured, position] index, no + # standalone index on position - a bare range with no equality column + # to pair with can use neither the plain range path (single-column + # only) nor the composite one (needs an equality prefix), so a scan is + # the correct, honest answer here, not a false "it found an index". + @store.reset_counts! + results = EqRangeIndexedPost.where(position: { gte: 3 }).to_a + assert_equal [3], results.map { |p| p['position'] } + assert_equal 1, @store.scan_count + assert_equal 0, @store.eq_range_hits, 'no equality column means no composite prefix to look up' + end + + def test_equality_without_any_range_falls_back_to_a_scan_not_the_composite_index + # Same reasoning as above, mirrored: this fixture has no standalone + # index on featured either, only the composite - a bare equality with + # no range column to pair with can't use a composite index that + # requires ALL its columns pinned (see Store#lookup_by_index), so this + # is a scan too, not a spurious composite hit. + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).to_a + assert_equal 3, results.size + assert_equal 1, @store.scan_count + assert_equal 0, @store.eq_range_hits + end + + def test_reflects_a_reorder_through_the_underlying_model + post = EqRangeIndexedPost.where(featured: true).where(position: 1).first + post.class.store.increment_counter(post.class.table_name, post.id, 'position', 10) + + @store.reset_counts! + results = EqRangeIndexedPost.where(featured: true).where(position: { lt: 3 }).to_a + assert_equal [2], results.map { |p| p['position'] } + assert_equal 0, @store.scan_count + end +end