test: cover OrmHasManyForeignKeyIndexTest and teardown

This commit is contained in:
dualfroz committed 2026-09-12 13:07:40 +02:00
1 parent 9a0294edde
commit 05bcfc48ef
1 file changed
+175
+175
View File
@@ -0,0 +1,175 @@
require 'minitest/autorun'
require 'prism'
require 'tmpdir'
# `has_many`'s generated reader (Associable#has_many, no `through:`/`as:`) builds
# `Target.where(foreign_key => id)` directly - a single HashNode equality - so it
# is answerable by Store#lookup_by_index exactly like any other `where(column: value)`
# once the foreign key column carries an index. Before this index exists, every call
# to `parent.children` full-scans the child table, even though it is one of the most
# frequently issued queries in any has_many-shaped app (examples/blog's `post.comments`
# runs on every single post page load).
class OrmHasManyForeignKeyIndexTest < Minitest::Test
def setup
@dir = Dir.mktmpdir('prism-fk-index-store')
@store = CountingFkIndexStore.new(File.join(@dir, 'test.pstore'))
@store.create_table(:parents, %w[id name])
@store.create_table(:children, %w[id parent_id label])
parent_class = Class.new(Prism::Orm::Model) do
table_name :parents
has_many :children, class_name: 'Child', foreign_key: 'parent_id'
end
child_class = Class.new(Prism::Orm::Model) { table_name :children }
[parent_class, child_class].each { |klass| klass.store = @store }
Object.const_set(:FkParent, parent_class)
Object.const_set(:Child, child_class)
@mom = FkParent.create!(name: 'mom')
@dad = FkParent.create!(name: 'dad')
Child.create!(parent_id: @mom.id, label: 'a')
Child.create!(parent_id: @mom.id, label: 'b')
Child.create!(parent_id: @dad.id, label: 'c')
end
def teardown
FileUtils.remove_entry(@dir)
%i[FkParent Child].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
end
def test_has_many_reader_full_scans_without_an_index_on_the_foreign_key
@store.reset_counts!
labels = @mom.children.to_a.map { |child| child['label'] }.sort
assert_equal %w[a b], labels
assert_equal 1, @store.scan_count, 'without an index, Target.where(foreign_key => id) must fall back to a scan'
assert_equal 0, @store.index_hits
end
def test_has_many_reader_never_scans_once_the_foreign_key_is_indexed
@store.add_index(:children, :parent_id)
@store.reset_counts!
labels = @mom.children.to_a.map { |child| child['label'] }.sort
assert_equal %w[a b], labels
assert_equal 0, @store.scan_count
assert_equal 1, @store.index_hits
@store.reset_counts!
dad_labels = @dad.children.to_a.map { |child| child['label'] }
assert_equal ['c'], dad_labels
assert_equal 0, @store.scan_count
end
def test_indexed_foreign_key_reflects_writes_immediately
@store.add_index(:children, :parent_id)
Child.create!(parent_id: @mom.id, label: 'd')
@store.reset_counts!
labels = @mom.children.to_a.map { |child| child['label'] }.sort
assert_equal %w[a b d], labels
assert_equal 0, @store.scan_count
end
end
# `has_many ..., as: :taggable` (Associable#define_has_many_polymorphic) builds a
# two-column HashNode equality (`likeable_id` + `likeable_type` in examples/blog's
# `Like`). Store#lookup_by_index only requires an index's own columns to be present
# in the equalities (Relation#rows always re-applies the full @condition afterward),
# so a single-column index on just `likeable_id` is already enough to skip the table
# scan - the leftover `likeable_type` equality is filtered from the (small) candidate
# set in memory instead of against the whole table. Both shapes are proven below.
class OrmHasManyPolymorphicForeignKeyIndexTest < Minitest::Test
def setup
@dir = Dir.mktmpdir('prism-fk-poly-index-store')
@store = CountingFkIndexStore.new(File.join(@dir, 'test.pstore'))
@store.create_table(:posts, %w[id title])
@store.create_table(:comments, %w[id body])
@store.create_table(:tags, %w[id taggable_id taggable_type label])
post_class = Class.new(Prism::Orm::Model) do
table_name :posts
has_many :tags, class_name: 'Tag', as: :taggable
end
comment_class = Class.new(Prism::Orm::Model) do
table_name :comments
has_many :tags, class_name: 'Tag', as: :taggable
end
tag_class = Class.new(Prism::Orm::Model) { table_name :tags }
[post_class, comment_class, tag_class].each { |klass| klass.store = @store }
Object.const_set(:TaggablePost, post_class)
Object.const_set(:TaggableComment, comment_class)
Object.const_set(:Tag, tag_class)
@post = TaggablePost.create!(title: 'hello')
@comment = TaggableComment.create!(body: 'hi')
Tag.create!(taggable_id: @post.id, taggable_type: 'TaggablePost', label: 'ruby')
Tag.create!(taggable_id: @comment.id, taggable_type: 'TaggableComment', label: 'ruby')
end
def teardown
FileUtils.remove_entry(@dir)
%i[TaggablePost TaggableComment Tag].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
end
def test_without_any_index_the_polymorphic_reader_falls_back_to_a_scan
@store.reset_counts!
labels = @post.tags.to_a.map { |tag| tag['label'] }
assert_equal ['ruby'], labels
assert_equal 1, @store.scan_count
end
def test_single_column_index_on_the_id_half_already_avoids_the_scan
@store.add_index(:tags, :taggable_id)
@store.reset_counts!
labels = @post.tags.to_a.map { |tag| tag['label'] }
assert_equal ['ruby'], labels
assert_equal 0, @store.scan_count,
'the index narrows candidates by id; the leftover type equality is filtered in memory, not by scanning'
end
def test_composite_index_over_both_columns_avoids_the_scan_and_stays_type_scoped
@store.add_index(:tags, %i[taggable_id taggable_type])
@store.reset_counts!
post_labels = @post.tags.to_a.map { |tag| tag['label'] }
assert_equal ['ruby'], post_labels
assert_equal 0, @store.scan_count
assert_equal 1, @store.index_hits
@store.reset_counts!
comment_labels = @comment.tags.to_a.map { |tag| tag['label'] }
assert_equal ['ruby'], comment_labels
assert_equal 0, @store.scan_count
end
end
# Wraps Store#all/#lookup_by_index so tests can assert on whether a query hit
# the index or fell back to a full table scan, instead of trusting that it
# "looks" indexed. Relation#rows only calls #all when indexed_candidates comes
# back nil, so #all's call count is exactly the full-scan count.
class CountingFkIndexStore < Prism::Orm::Store
attr_reader :scan_count, :index_hits
def initialize(*args)
super
@scan_count = 0
@index_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 reset_counts!
@scan_count = 0
@index_hits = 0
end
end