Home > Net >  Building associations from a form input with array of associated model id's
Building associations from a form input with array of associated model id's

Time:04-20

I have the models Game, Player and Country. I'm working on a form with nested fields for Player which should create the Player with it's associated Countries.

The array of country_ids are sent through a nested_player.hidden_field :country_ids as an array of values.

game:

class Game < ApplicationRecord
  has_and_belongs_to_many :players
  accepts_nested_attributes_for :players
end

player:

class Player < ApplicationRecord
  has_and_belongs_to_many :games
  has_many :countries
end

country:

class Country < ApplicationRecord
  belongs_to :player, optional: true
end

game controller:

  def game_params
    params.require(:game).permit(:metadata, players_attributes: [:name, :color, country_ids: []])
  end

form:

<%= simple_form_for @game do |f| %>

  <%= f.fields_for :players do |player| %>
    <%= player.input :name %>
    <%= player.input :color, as: :color %>
    <%= player.hidden_field :country_ids, value: ["226"] %>
  <% end %>

  <%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>

Problem:

The controller is receiving the country_ids as expected. The Game and nested Players are saved, but no player-country associations are built.

Parameters:

{"game"=>{"players_attributes"=>{"0"=>{"name"=>"foo", "color"=>"#000000", "country_ids"=>"226"}}},
 "commit"=>"Submit"}

CodePudding user response:

The way you have it set up at the moment will reassign Country#player_id each time you create a new game with a new player, that last player id will be in Country#player_id; Right now it is one country -> one player.

To fix it, add another join table between Country and Player.

# db/migrate/20220419040615_create_pink_floyd90_game.rb

class CreatePinkFloyd90Game < ActiveRecord::Migration[7.0]
  def change
    create_table :countries do |t|
      t.string :name
    end

    create_table :games do |t|
      t.string :name
    end

    create_table :players do |t|
      t.string :name 
    end

    create_join_table :countries, :players # fixed
    create_join_table :games, :players
  end
end
# app/models/*.rb

class Country < ApplicationRecord
  has_and_belongs_to_many :players
end

class Player < ApplicationRecord
  has_and_belongs_to_many :games
  has_and_belongs_to_many :countries
end

class Game < ApplicationRecord
  has_and_belongs_to_many :players
  accepts_nested_attributes_for :players
end

Before setting up a form, it's better to test the associations if they are not obvious:

>> Country.create!([{name: 'Country 1'}, {name: 'Country 2'}])

>> Game.create!(name: 'Game 1', players_attributes: {one: {name: 'Player 1', country_ids: [1,2]}})

# NOTE: notice the actual records that are created, and make sure this is the intention

# Load the referenced by ids countries (for validation, I think)
  Country Load (0.7ms)  SELECT "countries".* FROM "countries" WHERE "countries"."id" IN ($1, $2)  [["id", 1], ["id", 2]]

  TRANSACTION (0.3ms)  BEGIN

# Create a game
  Game Create (0.7ms)  INSERT INTO "games" ("name") VALUES ($1) RETURNING "id"  [["name", "Game 1"]]

# Create a player
  Player Create (0.7ms)  INSERT INTO "players" ("name") VALUES ($1) RETURNING "id"  [["name", "Player 1"]]

# Associate 'Country 1' with 'Player 1'
  Player::HABTM_Countries Create (0.5ms)  INSERT INTO "countries_players" ("country_id", "player_id") VALUES ($1, $2)  [["country_id", 1], ["player_id", 1]]

# Associate 'Country 2' with 'Player 1'
  Player::HABTM_Countries Create (0.3ms)  INSERT INTO "countries_players" ("country_id", "player_id") VALUES ($1, $2)  [["country_id", 2], ["player_id", 1]]

# Associate 'Game 1' with 'Player 1'
  Game::HABTM_Players Create (0.5ms)  INSERT INTO "games_players" ("game_id", "player_id") VALUES ($1, $2)  [["game_id", 1], ["player_id", 1]]

  TRANSACTION (2.8ms)  COMMIT
=> #<Game:0x00007f3ca4a82540 id: 1, name: "Game 1">

>> Game.first.players.pluck(:name)
=> ["Player 1"]                                                    
>> Player.first.countries.pluck(:name)                                                
=> ["Country 1", "Country 2"]      

Now I know this is working and anything unexpected will be in the controller or the form. Which is where the second issue is and the reason player-country did not associate.

{
  "game"=>{
    "players_attributes"=>{
      "0"=>{
        "name"=>"foo",
        "color"=>"#000000",
        "country_ids"=>"226" # doesn't look like an array
      }
    }
  },
  "commit"=>"Submit"
}

Because country_ids is not an array and it is not a hash that rails recognizes as array { "0"=>{}, "1"=>{} } permitted parameters do not allow it through.

Game.create(game_params) # <= never receives `country_ids`

Easy to check in rails console

https://api.rubyonrails.org/classes/ActionController/Parameters.html

# this is a regular attribute, not an array
>> params = {"country_ids"=>"1"}
>> ActionController::Parameters.new(params).permit(country_ids: []).to_h
=> {} # not allowed

# how about a nested hash
>> params = {"country_ids"=>{"0"=>{"id"=>"1"}}}
>> ActionController::Parameters.new(params).permit(country_ids: [:id]).to_h
=> {"country_ids"=>{"0"=>{"id"=>"1"}}} # allowed, but not usable without modifications

# how about an array
>> params = {"country_ids"=>["1","2"]}
>> ActionController::Parameters.new(params).permit(country_ids: []).to_h
=> {"country_ids"=>["1", "2"]} # TADA!

How to make a form submit an array.

Submitting an actual array is a bit of a hassle. The trick is to make the input name attribute end with []. For country_ids, input should look like this

<input value="1" type="text" name="game[players_attributes][0][country_ids][]">
<input value="2" type="text" name="game[players_attributes][0][country_ids][]">
# this will submit these parameters
# {"game"=>{"players_attributes"=>{"0"=>{"country_ids"=>["1", "2"]}}}

Form builders seem to not like that setup, so we have to do some shenanigans, especially for this nested setup:

form_for

<% game.players.build %>

<%= form_with model: game do |f| %>
  <%= f.fields_for :players do |ff| %>

    <%# using plain tag helper  %>
    <%# NOTE: `ff.object_name` returns "game[players_attributes][0]" %>
    <%= text_field_tag "#{ff.object_name}[country_ids][]", 1 %> <%# <input type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_" value="1"> %>
    <%= text_field_tag "#{ff.object_name}[country_ids][]", 2 %> <%# <input type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_" value="2"> %>

    <%# using `fields_for` helper  %>
    <%= ff.fields_for :country_ids do |fff| %>
      <%# NOTE: empty string '' gives us [] %>
      <%= fff.text_field '', value: 1 %> <%# <input value="1" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
      <%= fff.text_field '', value: 2 %> <%# <input value="2" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
    <% end %>

  <% end %>
  <%= f.submit %>
<% end %>

simple_form same thing

<% game.players.build %>

<%= simple_form_for game do |f| %>
  <%= f.simple_fields_for :players do |ff| %>

    <%= ff.simple_fields_for :country_ids do |fff| %>
      <%= fff.input '', input_html: { value: 1 } %> <%# <input value="1"  type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
      <%= fff.input '', input_html: { value: 2 } %> <%# <input value="2"  type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
    <% end %>

  <% end %>
<% end %>

Or forget all that because it's complicated and have country_ids as a plain string and split it in the controller

<%= simple_form_for game do |f| %>
  <%= f.simple_fields_for :players do |ff| %>
    <%= ff.input :country_ids, input_html: { value: [1,2] } %> <%# <input value="1 2"  type="text" name="game[players_attributes][0][country_ids]" id="game_players_attributes_0_country_ids"> %>
  <% end %>
  <%= f.submit %>
<% end %>
def game_params
  # modify only once
  @game_params ||= modify_params(
    params.require(:game).permit(players_attributes: [:name, :country_ids])
  )
end

def modify_params permitted
  # NOTE: take "1 2" and split into ["1", "2"]
  permitted[:players_attributes].each_value{|p| p[:country_ids] = p[:country_ids].split }
  permitted
end

def create
  Game.create(game_params)
end

Hope this isn't too confusing.

  • Related