jmil
jmil

Reputation: 55

Rails 3.1 Advanced Has_many and belongs_to model joins

I have two tables. Items, and Vendors. Items are sold by Vendors. So Item belongs_to :vendor and Vendor has_many :items. That works fine.

However, Items are not always manufactured by the Vendors that sell them, but sometimes they are. So I have a new column in my Item table called "manufacturer_id". Rather than generate a new model called Manufacturer that duplicates Vendor identically, I tried to do a complex has_many and belongs_to to define manufacturer.

See here:

class Item < ActiveRecord::Base
  belongs_to :vendor
  belongs_to :manufacturer, :class_name => "Vendor", :foreign_key => "manufacturer_id"
end

class Vendor < ActiveRecord::Base
  has_many :items
  has_many :manufactured_items, :class_name => "Item", :foreign_key => "manufacturer_id"
end

Populating manufacturer_id in the items table works as expected on Create commands:

Item.create(:manufacturer => Vendor.find_by_abbrev("INV"))

And I can even get the manufacturer as the operation

item.manufacturer

which returns:

<Vendor:0x007ff06684e398>

HOWEVER:

item.manufacturer.name

fails completely with a hard exeption and I get the error:

undefined method `name' for nil:NilClass

running

debug item.manufacturer

gives

--- !ruby/object:Vendor
attributes:
  id: 181
  name: Invitrogen
  website: http://www.invitrogen.com/
  created_at: 2012-01-08 01:39:07.486375000Z
  updated_at: 2012-01-08 01:39:07.486375000Z
  abbrev: INV

so item.manufacturer.name should return the name for that vendor object above, Vendor: 0x007ff06684e398.

What am I doing wrong here?

Also, once I get this working I'd like to be able to similarly call:

vendor.manufactured_items

to get all the items that have the manufacturer_id of that vendor. is there a straightforward way to do that too?

My last ditch effort may involve having to do:

manufacturer = Vendor.new(item.manufacturer)

But that seems totally wrong, and goes against the rails documentation here: http://guides.rubyonrails.org/association_basics.html#self-joins

please help!

Upvotes: 3

Views: 626

Answers (1)

jefflunt
jefflunt

Reputation: 33954

Okay, I've actually built a demo Rails 3.1 project for you and posted it on GitHub. I've included the console output in the README file to prove that calls like item.seller.name and item.manufacturer.name work, as well as round-trip calls like vendor.sold_items.first.manufacturer.name, which allow you to get the name of the manufacturer of the first sold item for a particular vendor, for example.

I think the root of the thing, as you noted, is that a vendor and a manufacturer, for all intents and purposes, are identical. For that reason I combined them simply into the Vendor class, and setup the foreign key relationships in such a way that it should work the way I think you want it to.

In particular, you should pay attention to the README file, which has the console session output that I ran to show it working. You'll also want to take a look at the two model classes and how their associations are defined, as well as the spec/factories.rb file for how it sets up the fake database data (I've included those below).

In re-reading your question this morning, I'm not sure what you were doing wrong, but you can probably chalk it up to a subtle error in your associations somewhere. It's probably a case of you being really close, but not quite there. :D

Here's some snipets from the code:

app/models/item.rb

class Item < ActiveRecord::Base
  belongs_to :seller, :class_name => "Vendor"
  belongs_to :manufacturer, :class_name => "Vendor"
end

app/models/vendor.rb

class Vendor < ActiveRecord::Base
  has_many :sold_items, :class_name => "Item", :foreign_key => :seller_id
  has_many :manufactured_items, :class_name => "Item", :foreign_key => :manufacturer_id
end

spec/factories.rb

require 'populator'
require 'faker'

FactoryGirl.define do

  factory :vendor do |v|
    v.name            {Populator.words(1..3)}
    v.website         {Faker::Internet.domain_name}
    v.abbr            {(["ABC", "DEF", "GHI", "JKL", "MNO", "PQR", "STU", "VWX", "YZ1"])[rand(9)]}
  end

  factory :item do |i|
    i.association :seller, :factory => :vendor
    i.association :manufacturer, :factory => :vendor
    i.name  {Populator.words(3..5)}
  end

end

lib/tasks/populator.rake

namespace :db do  
  desc "Erase database"
  task :erase => :environment do
    puts "Erasing..."

    [Vendor, Item].each(&:delete_all)
  end

  desc "Erase and fill database"
  task :populate => [:environment, :erase] do
    require 'populator'
    require 'faker'

    puts "Populating: enjoy this random pattern generator while you wait..."

    50.times{Factory.create(:vendor)}
    Vendor.all.each do |v|
      # This line actually has a bug in it that makes all `seller_id` and `manufacturer_id`
      # columns always contain a value in the range 0..50. That means
      # `rake db:populate` will only actually work the first time, but
      # I think you get the idea of how this should work.
      10.times{Factory.create(:item, :seller_id => (rand(50) + 1), :manufacturer_id => (rand(50) + 1))}
      print (['\\', '/', '_', '|'])[rand(4)]
    end

    puts ""
  end
end

Upvotes: 3

Related Questions