0

I have a has_many :through association. Players have many Teams and Teams have many Players. The join model, Affiliation, belongs to Players and Teams, and also has a year attribute to keep track of a player's team affiliation (or employment) from year to year.

I can't seem to figure out the right way to build an association based on the following rules:

  1. Create a new player.
  2. Associate a team that may be new or existing. So find it or create it, but only create it if the player is saved.
  3. The association may or may not include a year, but the association should only be created if the player and team are saved.

The Player model looks like:

class Player < ActiveRecord::Base
  attr_accessible :name
  
  has_many :affiliations, :dependent => :destroy
  has_many :teams, :through => :affiliations
end

The Team model looks like:

class Team < ActiveRecord::Base
  attr_accessible :city
  
  has_many :affiliations, :dependent => :destroy
  has_many :players, :through => :affiliations
end

The Affiliation model looks like:

class Affiliation < ActiveRecord::Base
  attr_accessible :player_id, :team_id, :year
  belongs_to :player
  belongs_to :team
end

I have been successful at creating the association records without the join model attribute using a create action in the PlayersController that looks like:

class PlayersController < ApplicationController
  def create
    @player = Player.new(params[:player].except(:teams))
    
    unless params[:player][:teams].blank?
      params[:player][:teams].each do |team|
        team_to_associate = Team.find_or_initialize_by_id(team[:id], team.except(:year)
        @player.teams << team_to_associate
      end
    end
    
    @player.save
    respond_with @player
  end
end

After creating a new player with two teams using params like:

{"player"=>{"name"=>"George Baker", "teams"=>[{"city"=>"Buffalo"}, {"city"=>"Detroit"}]}}

the database looks like:

players

id: 1, name: George Baker

teams

id: 1, city: Buffalo

id: 2, city: Seattle

affiliations

id: 1, player_id: 1, team_id: 1, year: null

id: 2, player_id: 1, team_id: 2, year: null

When I try to introduce the year, things fall apart. My most recent attempt at the create action in the PlayersController looks like:

class PlayersController < ApplicationController
  def create
    @player = Player.new(params[:player].except(:teams))
    
    unless params[:player][:teams].blank?
      params[:player][:teams].each do |team|
        team_to_associate = Team.find_or_initialize_by_id(team[:id], team.except(:year)
        // only additional line...
        team_to_associate.affiliations.build({:year => team[:year]})
        @player.teams << team_to_associate
      end
    end
    
    @player.save
    respond_with @player
  end
end

Now, when creating a new player with two teams using params like:

{"player"=>{"name"=>"Bill Johnson", "teams"=>[{"id"=>"1"}, {"city"=>"Detroit", "year"=>"1999"}]}}

the database looks like:

players

id: 1, name: George Baker

id: 2, name: Bill Johnson

teams

id: 1, city: Buffalo

id: 2, city: Seattle

id: 3, city: Detroit

affiliations

id: 1, player_id: 1, team_id: 1, year: null

id: 2, player_id: 1, team_id: 2, year: null

id: 3, player_id: 2, team_id: 1, year: null

id: 4, player_id: null, team_id: 3, year: 1999

id: 5, player_id: 2, team_id: 3, year: null

So three records were created when only two should have been. The affiliation record id: 3 is correct. For id: 4, the player_id is missing. And for id: 5, the year is missing.

Obviously this is incorrect. Where am I going wrong?

Thanks

Community
  • 1
  • 1
glevine
  • 697
  • 1
  • 7
  • 19

2 Answers2

0

Edit

Ok, i think i have a better solution. AFAIK, you can't use nested attributes on two levels of depth (though you could test it, maybe it works), but nothing prevents us to simulate this behavior :

class Player < ActiveRecord::Base
  has_many :affiliations
  has_many :teams, through: :affiliations
  accespts_nested_attributes_for :affiliations, allow_destroy: true
end

class Affiliation < ActiveRecord::Base
  belongs_to :player
  belongs_to :team

  validates :player, presence: true
  validates :team,   presence: true

  attr_accessor :team_attributes 

  before_validation :link_team_for_nested_assignment

  def link_team_for_nested_assignment
    return true unless team.blank?
    self.team = Team.find_or_create_by_id( team_attributes )
  end

Now, doing this :

@player = Player.new( 
            name: 'Bill Johnson', 
            affiliations_attributes: [
              {year: 1999, team_attributes: {id: 1, city: 'Detroit}},
              {team_attributes: {city: 'Somewhere else'}}
            ]
          )
@player.save

should create all the required records, and still rollback everything in case of problems (because the save itself is already wrapped in a transaction). As a bonus, all the errors will be associated to @player !

How about this ?

class PlayersController < ApplicationController
  def create

    ActiveRecord::Base.transaction do

      @player = Player.new(params[:player].except(:teams))
      raise ActiveRecord::Rollback unless @player.save # first check

      unless params[:player][:teams].blank?
        @teams = []
        params[:player][:teams].each do |team|

          team_to_associate = Team.find_or_initialize_by_id(team[:id], team.except(:year))
          raise ActiveRecord::Rollback unless team_to_associate.save # second check

          if team[:year]
            affiliation = team_to_associate.affiliations.build(player: @player, year: team[:year])
            raise ActiveRecord::Rollback unless affiliation.save # third check
          end
          @teams << team_to_associate # keep the object so we have access to errors
        end
      end
    end


      flash[:notice] = "ok"
  rescue ActiveRecord::Rollback => e
    flash[:alert]  = "nope"
  ensure
    respond_with @group
  end
end

m_x
  • 12,357
  • 7
  • 46
  • 60
  • I tried this out and it does work. So thank you! But I'm curious, is there a way to build the association so that `@group.save` saves everything? – glevine Nov 29 '12 at 13:48
  • it depends, i don't know what @group is, i just copy/pasted it from your answer – m_x Nov 29 '12 at 16:46
  • Sorry, that was a mistake. I was copying code from another example I had and I left `@group` in there. I edited my question so `@group` is now `@player`. Hopefully that clarifies it. – glevine Nov 29 '12 at 16:49
  • There's [nested attributes](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html) but i don't think you can use it with two association levels. – m_x Nov 29 '12 at 16:53
  • That's what I figured based on everything I've read and tried. My only question with the transaction is how exceptions are handled. create should raise exceptions, but I assume those won't get added to `@player.errors` (which is the default behavior of `@player.create`) for `Team.find_or_create_by_id` or `team_to_associate.create`. Can I catch the exceptions thrown by those calls and add them to `@player.errors` so I get the right exception message in my response? – glevine Nov 29 '12 at 17:03
  • my bad, i just remembered that the transactions do not rollback automatically if one of the operations is not successfull. You have to manually check if each operation is successfull, and return false or raise a Rollback error if it's not (i will update my answer to reflect that). Other wise, the errors are assigned normally. – m_x Nov 29 '12 at 18:40
  • thanks for all your help. I have everything working, when input is good. The only thing I'm still struggling with is the exceptions. The exceptions are being raised and everything is rolling back as expected. But I can't seem to get the errors in `@player.errors` Can you explain `@teams << team_to_associate` further? P.S. I'll happily move this to another question if that is best. Thanks. – glevine Dec 01 '12 at 03:05
  • I realized that the exception wasn't being rescued because Rollback exceptions don't get re-raised. I moved the exception question to a [new question](http://stackoverflow.com/questions/13656587/). Just figured it belonged elsewhere. However, I'm still not sure what you meant by "keep the object so we have access to errors", so further explanation would be greatly appreciated. Thanks. – glevine Dec 01 '12 at 05:42
  • My bad again, to have access to the errors you have to move the `<<` up, so that for instance, if a team causes a rollback, you have access to it in the `@teams` array, so you can do `@teams.last.errors`. To be honest, i'm not so sure this is a good solution anymore (one should not rely on raising errors as part of the normal workflow), i'll try to dig it further if i have some time. I'll read your other question to see if i can help. – m_x Dec 01 '12 at 12:16
  • That got me so close! But I was running into an error stating `unknown attribute: team_attributes`. Seeing how you approached it though -- the use of the model method in Affiliation to link the location -- led me to a solution. I'm posting the code in another answer in case it helps anyone else. You've been a great help @m_x. Thank you. – glevine Dec 02 '12 at 05:25
0

This solution ended up working for me. If anyone uses this code for their own project, however, please know that I haven't tested any other actions besides create. I'm certain some of this will change once I deal with read, update and delete.

class Player < ActiveRecord::Base
  attr_accessible :name

  has_many :affiliations, :dependent => :destroy
  has_many :teams, :through => :affiliations
  accepts_nested_attributes_for :affiliations, :allow_destroy => true
  attr_accessible :affiliations_attributes
end

class Team < ActiveRecord::Base
  attr_accessible :city

  has_many :affiliations, :dependent => :destroy
  has_many :players, :through => :affiliations
end

class Affiliation < ActiveRecord::Base
  attr_accessible :player_id, :team_id, :team_attributes, :year
  belongs_to :player
  belongs_to :team
  accepts_nested_attributes_for :team

  def team_attributes=(team_attributes)
    self.team = Team.find_by_id(team_attributes[:id])
    self.team = Team.new(team_attributes.except(:id)) if self.team.blank?
  end
end

class PlayersController < ApplicationController
  def create
    player_params = params[:player].except(:teams)
    affiliation_params = []

    unless params[:player][:teams].blank?
      params[:player][:teams].each do |team|
        affiliation = {}
        affiliation[:year] = team[:year] unless team[:year].blank?
        affiliation[:team_attributes] = team.except(:year)
        affiliation_params << affiliation
      end
    end

    player_params[:affiliation_attributes] = affiliation_params unless affiliation_params.blank?

    @player = Player.new(player_params)
    @player.save

    respond_with @player
  end
end
glevine
  • 697
  • 1
  • 7
  • 19