从表单输入与关联模型 ID 的数组建立关联

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

我有模型游戏、玩家和国家。我正在为 Player 开发一个带有嵌套字段的表单,它应该创建具有相关国家/地区的播放器。

country_ids 的数组作为值数组通过 nested_player.hidden_field :country_ids 发送。

游戏:

class Game < ApplicationRecord
  has_and_belongs_to_many :players
  accepts_nested_attributes_for :players
end

玩家:

class Player < ApplicationRecord
  has_and_belongs_to_many :games
  has_many :countries
end

国家:

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

游戏控制器:

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

形式:

<%= 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 %>

问题:

控制器正在按预期接收 country_ids。游戏和嵌套 Player 已保存,但未建立玩家-国家/地区关联。

参数:

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

您目前的设置方式会在您每次创建新游戏时重新分配 Country#player_id 新玩家,最后一个玩家 ID 将在 Country#player_id 中;现在是一个国家 -> 一个玩家。

要修复它,请在国家和玩家之间添加另一个连接 table。

# 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

在设置表格之前,如果关联不明显,最好测试一下:

>> 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 (, )  [["id", 1], ["id", 2]]

  TRANSACTION (0.3ms)  BEGIN

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

# Create a player
  Player Create (0.7ms)  INSERT INTO "players" ("name") VALUES () 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 (, )  [["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 (, )  [["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 (, )  [["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"]      

现在我知道这是可行的,任何意外的事情都会出现在控制器或表单中。第二个问题在哪,原因player-country没有关联。

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

因为country_ids不是数组,也不是rails识别为数组的散列{ "0"=>{}, "1"=>{} }允许的参数不允许通过。

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

易于签入rails控制台

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!

如何使表单提交数组。

提交实际数组有点麻烦。诀窍是使输入 name 属性以 [] 结尾。对于 country_ids,输入应如下所示

<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_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一样

<% 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" class="string required" 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" class="string required" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
    <% end %>

  <% end %>
<% end %>

或者忘记所有这些,因为它很复杂,并且将 country_ids 作为纯字符串并在控制器中拆分它

<%= 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" class="string optional" 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

希望这不会太混乱。