require 'minitest/autorun' require 'prism' require 'tmpdir' class OrmIndexStoreTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-index-store') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:users, %w[id email age]) end def teardown FileUtils.remove_entry(@dir) end def test_add_index_returns_a_default_name_and_lists_it name = @store.add_index(:users, :email) assert_equal 'index_users_on_email', name assert_equal [{ name: 'index_users_on_email', columns: ['email'], unique: false }], @store.indexes(:users) end def test_add_index_accepts_an_explicit_name name = @store.add_index(:users, :email, name: 'idx_email') assert_equal 'idx_email', name end def test_add_index_twice_with_the_same_name_raises @store.add_index(:users, :email) assert_raises(Prism::Orm::Store::IndexExists) { @store.add_index(:users, :email) } end def test_add_index_backfills_existing_rows @store.insert(:users, email: 'a@x.com', age: 20) @store.insert(:users, email: 'b@x.com', age: 30) @store.add_index(:users, :email) matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) assert_equal ['a@x.com'], matches.map { |row| row['email'] } end def test_add_unique_index_over_existing_duplicates_raises_and_creates_nothing @store.insert(:users, email: 'dup@x.com', age: 20) @store.insert(:users, email: 'dup@x.com', age: 30) assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do @store.add_index(:users, :email, unique: true) end assert_equal [], @store.indexes(:users) end def test_remove_index_by_name @store.add_index(:users, :email, name: 'idx_email') @store.remove_index(:users, 'idx_email') assert_equal [], @store.indexes(:users) end def test_remove_index_by_column_list @store.add_index(:users, :email) @store.remove_index(:users, :email) assert_equal [], @store.indexes(:users) end def test_remove_unknown_index_raises assert_raises(Prism::Orm::Store::UnknownIndex) { @store.remove_index(:users, :email) } end def test_lookup_by_index_returns_nil_without_a_covering_index @store.insert(:users, email: 'a@x.com', age: 20) assert_nil @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) end def test_lookup_by_index_returns_empty_array_for_no_match @store.add_index(:users, :email) @store.insert(:users, email: 'a@x.com', age: 20) assert_equal [], @store.lookup_by_index(:users, { 'email' => 'nobody@x.com' }) end def test_composite_index_requires_every_column_present_in_the_lookup @store.add_index(:users, [:email, :age]) @store.insert(:users, email: 'a@x.com', age: 20) assert_nil @store.lookup_by_index(:users, { 'email' => 'a@x.com' }), 'a lookup missing one of the composite columns should not use this index' matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com', 'age' => 20 }) assert_equal 1, matches.size end def test_lookup_by_index_prefers_the_most_specific_covering_index @store.add_index(:users, :email) @store.add_index(:users, [:email, :age]) @store.insert(:users, email: 'a@x.com', age: 20) @store.insert(:users, email: 'a@x.com', age: 99) matches = @store.lookup_by_index(:users, { 'email' => 'a@x.com', 'age' => 20 }) assert_equal [20], matches.map { |row| row['age'] } end def test_unique_index_rejects_a_duplicate_insert @store.add_index(:users, :email, unique: true) @store.insert(:users, email: 'a@x.com', age: 20) assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do @store.insert(:users, email: 'a@x.com', age: 30) end assert_equal 1, @store.count(:users), 'the rejected insert must not have partially applied' end def test_unique_index_rejects_an_update_that_collides_with_another_row @store.add_index(:users, :email, unique: true) id1 = @store.insert(:users, email: 'a@x.com', age: 20) id2 = @store.insert(:users, email: 'b@x.com', age: 30) assert_raises(Prism::Orm::Store::UniqueConstraintViolation) do @store.update(:users, id2, email: 'a@x.com') end assert_equal 'b@x.com', @store.find(:users, id2)['email'], 'the rejected update must not have partially applied' assert_equal 'a@x.com', @store.find(:users, id1)['email'] end def test_unique_index_allows_updating_a_row_to_its_own_current_value @store.add_index(:users, :email, unique: true) id = @store.insert(:users, email: 'a@x.com', age: 20) @store.update(:users, id, age: 21) assert_equal 21, @store.find(:users, id)['age'] end def test_index_tracks_updates_old_key_freed_new_key_claimed @store.add_index(:users, :email) id = @store.insert(:users, email: 'a@x.com', age: 20) @store.update(:users, id, email: 'b@x.com') assert_equal [], @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) assert_equal [id], @store.lookup_by_index(:users, { 'email' => 'b@x.com' }).map { |row| row['id'] } end def test_index_forgets_deleted_rows @store.add_index(:users, :email) id = @store.insert(:users, email: 'a@x.com', age: 20) @store.delete(:users, id) assert_equal [], @store.lookup_by_index(:users, { 'email' => 'a@x.com' }) end def test_deleting_a_nonexistent_id_is_still_a_silent_no_op @store.add_index(:users, :email) @store.delete(:users, 999) end end # add_column/remove_column must keep any index that covers the affected # column in sync - see the comments on Store#add_column/#remove_column for # why a stale map here is a silent-wrong-results bug (Relation#rows trusts # a non-nil #lookup_by_index result as definitive), not just a missed # optimization. class OrmIndexColumnMigrationTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-index-column-migration') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:posts, %w[id title]) end def teardown FileUtils.remove_entry(@dir) end def test_add_column_backfill_rebuilds_an_index_built_before_the_column_existed id1 = @store.insert(:posts, title: 'first') id2 = @store.insert(:posts, title: 'second') @store.add_index(:posts, :status) @store.add_column(:posts, :status, default: 'draft') matches = @store.lookup_by_index(:posts, { 'status' => 'draft' }) refute_nil matches, 'the index should still cover the column after the backfill' assert_equal [id1, id2].sort, matches.map { |row| row['id'] }.sort end def test_add_column_backfill_drops_the_stale_nil_key_from_the_index @store.insert(:posts, title: 'first') @store.add_index(:posts, :status) @store.add_column(:posts, :status, default: 'draft') assert_equal [], @store.lookup_by_index(:posts, { 'status' => nil }), 'no row should still be indexed under the pre-backfill nil value' end def test_add_column_backfill_keeps_a_range_capable_index_usable id1 = @store.insert(:posts, title: 'first') id2 = @store.insert(:posts, title: 'second') @store.add_index(:posts, :priority) @store.add_column(:posts, :priority, default: 5) matches = @store.lookup_by_index_range(:posts, :priority, gte: 1) refute_nil matches assert_equal [id1, id2].sort, matches.map { |row| row['id'] }.sort end def test_remove_column_drops_a_single_column_index_covering_it @store.add_index(:posts, :status, name: 'idx_status') @store.remove_column(:posts, :status) assert_equal [], @store.indexes(:posts) assert_nil @store.lookup_by_index(:posts, { 'status' => 'draft' }), 'a dropped column must never be reported as indexed again' end def test_remove_column_drops_a_composite_index_covering_it @store.add_column(:posts, :status, default: 'draft') @store.add_index(:posts, %i[title status]) @store.remove_column(:posts, :title) assert_equal [], @store.indexes(:posts), 'a composite index cannot survive with one of its columns gone' end def test_remove_column_leaves_indexes_on_other_columns_untouched @store.add_column(:posts, :status, default: 'draft') @store.add_index(:posts, :title) @store.add_index(:posts, :status) @store.remove_column(:posts, :status) assert_equal ['index_posts_on_title'], @store.indexes(:posts).map { |index| index[:name] } end def test_remove_column_then_readding_it_starts_with_a_clean_index_free_slate id = @store.insert(:posts, title: 'first') @store.add_index(:posts, :status) @store.add_column(:posts, :status, default: 'draft') @store.remove_column(:posts, :status) @store.add_column(:posts, :status, default: 'archived') assert_equal [], @store.indexes(:posts), 'removing the column must have dropped the earlier index for good' assert_equal 'archived', @store.find(:posts, id)['status'] end end class OrmIndexRangeStoreTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-index-range-store') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:users, %w[id email age]) end def teardown FileUtils.remove_entry(@dir) end def test_single_column_index_is_range_capable_and_sorted @store.insert(:users, email: 'c@x.com', age: 30) @store.insert(:users, email: 'a@x.com', age: 10) @store.insert(:users, email: 'b@x.com', age: 20) @store.add_index(:users, :age) matches = @store.lookup_by_index_range(:users, :age, gte: 20) assert_equal [20, 30], matches.map { |row| row['age'] }.sort end def test_composite_index_is_never_range_capable @store.add_index(:users, [:email, :age]) @store.insert(:users, email: 'a@x.com', age: 10) assert_nil @store.lookup_by_index_range(:users, :age, gte: 0) end def test_lookup_by_index_range_returns_nil_without_a_covering_index @store.insert(:users, email: 'a@x.com', age: 10) assert_nil @store.lookup_by_index_range(:users, :age, gte: 0) end def test_gte_is_inclusive @store.add_index(:users, :age) @store.insert(:users, email: 'a@x.com', age: 20) matches = @store.lookup_by_index_range(:users, :age, gte: 20) assert_equal 1, matches.size end def test_gt_is_exclusive @store.add_index(:users, :age) @store.insert(:users, email: 'a@x.com', age: 20) assert_equal [], @store.lookup_by_index_range(:users, :age, gt: 20) end def test_lte_is_inclusive @store.add_index(:users, :age) @store.insert(:users, email: 'a@x.com', age: 20) matches = @store.lookup_by_index_range(:users, :age, lte: 20) assert_equal 1, matches.size end def test_lt_is_exclusive @store.add_index(:users, :age) @store.insert(:users, email: 'a@x.com', age: 20) assert_equal [], @store.lookup_by_index_range(:users, :age, lt: 20) end def test_combined_lower_and_upper_bound @store.add_index(:users, :age) [5, 15, 25, 35, 45].each { |age| @store.insert(:users, email: "#{age}@x.com", age: age) } matches = @store.lookup_by_index_range(:users, :age, gte: 15, lte: 35) assert_equal [15, 25, 35], matches.map { |row| row['age'] }.sort end def test_nil_valued_rows_are_never_returned_by_a_range_lookup @store.add_index(:users, :age) @store.insert(:users, email: 'a@x.com', age: nil) @store.insert(:users, email: 'b@x.com', age: 10) matches = @store.lookup_by_index_range(:users, :age, gte: 0) assert_equal ['b@x.com'], matches.map { |row| row['email'] } end def test_index_backfilled_from_existing_rows_is_range_capable @store.insert(:users, email: 'a@x.com', age: 10) @store.insert(:users, email: 'b@x.com', age: 20) @store.add_index(:users, :age) matches = @store.lookup_by_index_range(:users, :age, gte: 15) assert_equal ['b@x.com'], matches.map { |row| row['email'] } end def test_range_lookup_tracks_updates @store.add_index(:users, :age) id = @store.insert(:users, email: 'a@x.com', age: 10) @store.update(:users, id, age: 99) assert_equal [id], @store.lookup_by_index_range(:users, :age, gte: 90).map { |r| r['id'] } assert_equal [], @store.lookup_by_index_range(:users, :age, gte: 5, lte: 10) end def test_range_lookup_forgets_deleted_rows @store.add_index(:users, :age) id = @store.insert(:users, email: 'a@x.com', age: 10) @store.delete(:users, id) assert_equal [], @store.lookup_by_index_range(:users, :age, gte: 0) end def test_reused_value_after_update_and_reinsert_still_resolves_correctly @store.add_index(:users, :age) a = @store.insert(:users, email: 'a@x.com', age: 10) @store.insert(:users, email: 'b@x.com', age: 10) @store.update(:users, a, age: 50) assert_equal ['b@x.com'], @store.lookup_by_index_range(:users, :age, gte: 0, lte: 20).map { |r| r['email'] } assert_equal ['a@x.com'], @store.lookup_by_index_range(:users, :age, gte: 40).map { |r| r['email'] } end end # Covers Store#lookup_by_index_in - the IN-list lookup backing # `where(column: [v1, v2, ...])`, most importantly `where(id: [...])`, # which every belongs_to/has_one/has_many/has_many-through eager-load # batches its preload query through (see Relation#preload_belongs_to and # friends). class OrmIndexInListStoreTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-index-in-store') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:users, %w[id email age]) end def teardown FileUtils.remove_entry(@dir) end def test_id_lookup_needs_no_add_index_call_at_all a = @store.insert(:users, email: 'a@x.com', age: 10) @store.insert(:users, email: 'b@x.com', age: 20) c = @store.insert(:users, email: 'c@x.com', age: 30) matches = @store.lookup_by_index_in(:users, :id, [a, c]) assert_equal ['a@x.com', 'c@x.com'], matches.map { |row| row['email'] }.sort end def test_id_lookup_ignores_ids_that_do_not_exist a = @store.insert(:users, email: 'a@x.com', age: 10) matches = @store.lookup_by_index_in(:users, :id, [a, a + 1000]) assert_equal [a], matches.map { |row| row['id'] } end def test_id_lookup_deduplicates_repeated_values a = @store.insert(:users, email: 'a@x.com', age: 10) matches = @store.lookup_by_index_in(:users, :id, [a, a, a]) assert_equal 1, matches.size end def test_lookup_by_index_in_returns_nil_without_a_covering_index_on_a_non_id_column @store.insert(:users, email: 'a@x.com', age: 10) assert_nil @store.lookup_by_index_in(:users, :email, ['a@x.com']) end def test_lookup_by_index_in_uses_a_single_column_index_when_present @store.insert(:users, email: 'a@x.com', age: 10) @store.insert(:users, email: 'b@x.com', age: 20) @store.insert(:users, email: 'c@x.com', age: 30) @store.add_index(:users, :email) matches = @store.lookup_by_index_in(:users, :email, ['a@x.com', 'c@x.com']) assert_equal ['a@x.com', 'c@x.com'], matches.map { |row| row['email'] }.sort end def test_composite_index_cannot_answer_an_in_list_on_one_of_its_columns @store.add_index(:users, [:email, :age]) @store.insert(:users, email: 'a@x.com', age: 10) assert_nil @store.lookup_by_index_in(:users, :email, ['a@x.com']) end def test_in_list_lookup_tracks_updates @store.add_index(:users, :email) id = @store.insert(:users, email: 'a@x.com', age: 10) @store.update(:users, id, email: 'new@x.com') assert_equal [], @store.lookup_by_index_in(:users, :email, ['a@x.com']) assert_equal [id], @store.lookup_by_index_in(:users, :email, ['new@x.com']).map { |r| r['id'] } end def test_in_list_lookup_forgets_deleted_rows @store.add_index(:users, :email) id = @store.insert(:users, email: 'a@x.com', age: 10) @store.delete(:users, id) assert_equal [], @store.lookup_by_index_in(:users, :email, ['a@x.com']) assert_equal [], @store.lookup_by_index_in(:users, :id, [id]) end def test_an_empty_values_list_matches_nothing_without_touching_the_table @store.insert(:users, email: 'a@x.com', age: 10) assert_equal [], @store.lookup_by_index_in(:users, :id, []) end end # Counts full-table scans (Store#all) separately from index lookups # (Store#lookup_by_index that actually found a covering index), the same # shape as CountingStore/CountingThroughStore used by the eager-loading # tests, so Relation's index usage can be proven rather than assumed. class CountingIndexStore < Prism::Orm::Store attr_reader :scan_count attr_reader :range_hits attr_reader :in_hits attr_reader :eq_range_hits def initialize(*args) super @scan_count = 0 @index_hits = 0 @range_hits = 0 @in_hits = 0 @eq_range_hits = 0 end def all(table) @scan_count += 1 super end def lookup_by_index(table, equalities) result = super @index_hits += 1 unless result.nil? result end def lookup_by_index_range(table, column, operators) result = super @range_hits += 1 unless result.nil? result end def lookup_by_index_in(table, column, values) result = super @in_hits += 1 unless result.nil? result end def lookup_by_index_equality_range(table, equalities, column, operators) result = super @eq_range_hits += 1 unless result.nil? 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