diff --git a/test/test_orm_polymorphic.rb b/test/test_orm_polymorphic.rb new file mode 100644 index 0000000..beb2c61 --- /dev/null +++ b/test/test_orm_polymorphic.rb @@ -0,0 +1,174 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' + +class OrmPolymorphicTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-polymorphic-store') + @store = CountingPolymorphicStore.new(File.join(@dir, 'test.pstore')) + @store.create_table(:posts, %w[id title]) + @store.create_table(:photos, %w[id caption]) + @store.create_table(:likes, %w[id likeable_id likeable_type user_id]) + @store.create_table(:previews, %w[id previewable_id previewable_type url]) + + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + has_many :likes, class_name: 'Like', as: :likeable, dependent: :destroy + has_one :preview, class_name: 'Preview', as: :previewable, dependent: :nullify + end + photo_class = Class.new(Prism::Orm::Model) do + table_name :photos + has_many :likes, class_name: 'Like', as: :likeable, dependent: :destroy + end + like_class = Class.new(Prism::Orm::Model) do + table_name :likes + belongs_to :likeable, polymorphic: true + end + preview_class = Class.new(Prism::Orm::Model) do + table_name :previews + belongs_to :previewable, polymorphic: true + end + [post_class, photo_class, like_class, preview_class].each { |klass| klass.store = @store } + Object.const_set(:Post, post_class) + Object.const_set(:Photo, photo_class) + Object.const_set(:Like, like_class) + Object.const_set(:Preview, preview_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Post Photo Like Preview].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_polymorphic_belongs_to_resolves_the_right_class + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + like_on_post = Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + like_on_photo = Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + + assert_instance_of Post, like_on_post.likeable + assert_equal post.id, like_on_post.likeable.id + assert_instance_of Photo, like_on_photo.likeable + assert_equal photo.id, like_on_photo.likeable.id + end + + def test_polymorphic_belongs_to_setter_stamps_both_columns + post = Post.create!(title: 'hello') + like = Like.new(user_id: 1) + like.likeable = post + like.save! + + assert_equal post.id, like.likeable_id + assert_equal 'Post', like.likeable_type + end + + def test_polymorphic_belongs_to_returns_nil_without_a_target + like = Like.create!(user_id: 1) + assert_nil like.likeable + end + + def test_polymorphic_has_many_scopes_by_type_not_just_id + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + # Deliberately share the same numeric id across two different owner + # types - the whole point of the type column is that this must not + # cause cross-talk between Post#likes and Photo#likes. + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: post.id, likeable_type: 'Photo', user_id: 2) + + assert_equal 1, post.likes.count + assert_equal 1, post.likes.first.user_id + end + + def test_polymorphic_has_many_dependent_destroy_only_removes_own_type + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 1) + + post.destroy + + assert_equal 1, Like.all.count + assert_equal 'Photo', Like.all.first.likeable_type + end + + def test_polymorphic_has_one_dependent_nullify_clears_both_columns + post = Post.create!(title: 'hello') + preview = Preview.create!(previewable_id: post.id, previewable_type: 'Post', url: '/p.png') + + post.destroy + + reloaded = Preview.find(preview.id) + assert_nil reloaded.previewable_id + assert_nil reloaded.previewable_type + end + + def test_polymorphic_has_one_setter_stamps_both_columns + post = Post.create!(title: 'hello') + preview = Preview.create!(url: '/p.png') + post.preview = preview + + reloaded = Preview.find(preview.id) + assert_equal post.id, reloaded.previewable_id + assert_equal 'Post', reloaded.previewable_type + end + + def test_includes_polymorphic_belongs_to_batches_across_mixed_types + post = Post.create!(title: 'hello') + photo = Photo.create!(caption: 'sunset') + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 1) + Like.create!(likeable_id: photo.id, likeable_type: 'Photo', user_id: 2) + Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: 3) + + @store.reset_query_count! + likes = Like.all.includes(:likeable).to_a + baseline = @store.query_count + + targets = likes.map { |like| like.likeable.class.name }.sort + assert_equal baseline, @store.query_count, + "expected preloaded #likeable to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal %w[Photo Post Post], targets + end + + def test_includes_polymorphic_has_many_avoids_n_plus_one_queries + 3.times do |i| + post = Post.create!(title: "post#{i}") + 2.times { |j| Like.create!(likeable_id: post.id, likeable_type: 'Post', user_id: j) } + end + Photo.create!(caption: 'unrelated') + + @store.reset_query_count! + posts = Post.all.includes(:likes).to_a + baseline = @store.query_count + + total = posts.sum { |post| post.likes.size } + assert_equal baseline, @store.query_count, + "expected preloaded #likes to be free, but it issued #{@store.query_count - baseline} more queries" + assert_equal 6, total + end +end + +# Mirrors CountingStore/CountingThroughStore from the eager-loading tests - +# a small local double so this file has no load-order dependency on them. +class CountingPolymorphicStore < Prism::Orm::Store + def initialize(*) + super + @query_count = 0 + end + + attr_reader :query_count + + def reset_query_count! + @query_count = 0 + end + + def all(table) + @query_count += 1 + super + end + + def find(table, id) + @query_count += 1 + super + end +end