require 'minitest/autorun' require 'prism' require 'tmpdir' class OrmHasOneThroughTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-has-one-through-store') @store = CountingHasOneThroughStore.new(File.join(@dir, 'test.pstore')) @store.create_table(:suppliers, %w[id name]) @store.create_table(:accounts, %w[id supplier_id plan]) @store.create_table(:account_histories, %w[id account_id notes]) supplier_class = Class.new(Prism::Orm::Model) do table_name :suppliers has_one :account, class_name: 'Account', foreign_key: 'supplier_id' has_one :account_history, through: :account end account_class = Class.new(Prism::Orm::Model) do table_name :accounts belongs_to :supplier, class_name: 'Supplier', foreign_key: 'supplier_id' has_one :account_history, class_name: 'AccountHistory', foreign_key: 'account_id' end history_class = Class.new(Prism::Orm::Model) do table_name :account_histories belongs_to :account, class_name: 'Account', foreign_key: 'account_id' has_one :supplier, through: :account end [supplier_class, account_class, history_class].each { |klass| klass.store = @store } Object.const_set(:Supplier, supplier_class) Object.const_set(:Account, account_class) Object.const_set(:AccountHistory, history_class) end def teardown FileUtils.remove_entry(@dir) %i[Supplier Account AccountHistory].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } end def test_has_one_through_reads_source_off_a_has_one_through_record supplier = Supplier.create!(name: 'acme') account = Account.create!(supplier_id: supplier.id, plan: 'gold') history = AccountHistory.create!(account_id: account.id, notes: 'upgraded') assert_equal history.id, supplier.account_history.id end def test_has_one_through_reads_source_off_a_belongs_to_through_record supplier = Supplier.create!(name: 'acme') account = Account.create!(supplier_id: supplier.id, plan: 'gold') history = AccountHistory.create!(account_id: account.id, notes: 'upgraded') assert_equal supplier.id, history.supplier.id end def test_has_one_through_returns_nil_without_through_record supplier = Supplier.create!(name: 'lonely') assert_nil supplier.account_history end def test_has_one_through_returns_nil_when_through_exists_but_source_missing supplier = Supplier.create!(name: 'acme') Account.create!(supplier_id: supplier.id, plan: 'gold') assert_nil supplier.account_history end def test_has_one_through_honors_explicit_source_when_name_differs history_with_alias = Class.new(Prism::Orm::Model) do table_name :account_histories belongs_to :account, class_name: 'Account', foreign_key: 'account_id' has_one :owner, through: :account, source: :supplier end history_with_alias.store = @store supplier = Supplier.create!(name: 'acme') account = Account.create!(supplier_id: supplier.id, plan: 'gold') record = history_with_alias.create!(account_id: account.id, notes: 'n/a') assert_equal supplier.id, record.owner.id end def test_has_one_through_rejects_dependent_option error = assert_raises(ArgumentError) do Class.new(Prism::Orm::Model) do table_name :suppliers has_one :account, class_name: 'Account', foreign_key: 'supplier_id' has_one :account_history, through: :account, dependent: :destroy end end assert_match(/dependent/, error.message) end def test_includes_has_one_through_avoids_n_plus_one_queries 3.times do |i| supplier = Supplier.create!(name: "supplier#{i}") account = Account.create!(supplier_id: supplier.id, plan: 'gold') AccountHistory.create!(account_id: account.id, notes: "notes#{i}") end lonely = Supplier.create!(name: 'no account') @store.reset_query_count! suppliers = Supplier.all.includes(:account_history).to_a baseline = @store.query_count notes = suppliers.map { |supplier| supplier.account_history&.notes } assert_equal baseline, @store.query_count, "expected preloaded #account_history to be free, but it issued #{@store.query_count - baseline} more queries" assert_equal ['notes0', 'notes1', 'notes2', nil], notes.compact.sort + [nil] assert_includes suppliers.map(&:name), lonely.name end # Mirrors test_includes_has_one_through_avoids_n_plus_one_queries above, # but from the AccountHistory side where the through step (:account) is a # belongs_to rather than a has_one - exercises the `when :belongs_to then # preload_belongs_to(...)` branch in preload_has_one_through specifically, # which the has_one-through variant above never touches. def test_includes_has_one_through_by_belongs_to_avoids_n_plus_one_queries 3.times do |i| supplier = Supplier.create!(name: "supplier#{i}") account = Account.create!(supplier_id: supplier.id, plan: 'gold') AccountHistory.create!(account_id: account.id, notes: "notes#{i}") end @store.reset_query_count! histories = AccountHistory.all.includes(:supplier).to_a baseline = @store.query_count names = histories.map { |history| history.supplier.name }.sort assert_equal baseline, @store.query_count, "expected preloaded #supplier to be free, but it issued #{@store.query_count - baseline} more queries" assert_equal %w[supplier0 supplier1 supplier2], names end def test_includes_has_one_through_unknown_through_raises broken_class = Class.new(Prism::Orm::Model) do table_name :suppliers has_one :account_history, through: :nonexistent end broken_class.store = @store broken_class.create!(name: 'x') assert_raises(ArgumentError) { broken_class.all.includes(:account_history).to_a } end def test_includes_has_one_through_rejects_has_many_through_association broken_class = Class.new(Prism::Orm::Model) do table_name :suppliers has_many :accounts, class_name: 'Account', foreign_key: 'supplier_id' has_one :account_history, through: :accounts end broken_class.store = @store broken_class.create!(name: 'x') assert_raises(ArgumentError) { broken_class.all.includes(:account_history).to_a } end end # Wraps Store#all/#find to count reads, mirroring CountingThroughStore in # test_orm_has_many_through.rb so this file has no load-order dependency. class CountingHasOneThroughStore < 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