What's the problem with my M2M?

I started a little project about games to learn Ruby. I would like to associate each game in my table games with many tags, which I defined in my genres table.

Currently, I’ve got a has_many link to genres (also tried has_and_belong_to_many), and used a collection_check_boxes in my edit view to select the genres, but the info is never sent in this column is never sent to the db

Can you post your schema.rb on this thread ? – H

Here you go :

create_table "games", force: :cascade do |t|
  t.string "name"
  t.text "description"
  t.string "steam"
  t.string "website"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.integer "genres_id"
  t.index ["genres_id"], name: "index_games_on_genres_id"
end

create_table "genres", force: :cascade do |t|
  t.string "name"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

Welcome Anthony to the wonderful world of Rails! Hopefully this post can help get your many:many working properly.

Given your current setup, having only one foreign key in the games table, each game can only have one genre. And currently one genre could be set up with multiple games, as more than one game could point to it with its foreign key.

In order to convert your setup to be able to do many to many (N:M) then you need an “associative table” – that is – a table in the middle which has the foreign keys. Then multiple associations could be established in both directions – not only could genres relate to multiple games, but also games could have multiple genres. (Sometimes this kind of middle table is called a “join table”.) This associative table will have foreign keys to both games and genres.

to get started, first create a model for the new table:

# app/models/game_genre.rb
class GameGenre < ApplicationRecord
  belongs_to :game
  belongs_to :genre
end

With that in place then the following two migrations can be run, this first one to build out an associative table in the database:

# db/migrate/20220731161900_create_game_genres.rb
class CreateGameGenres < ActiveRecord::Migration[7.0]
  def change
    # Adds an associative table which will hold N:M associations between games and genres
    create_table :game_genres do |t|
      t.references :game, foreign_key: { to_table: :games }, index: true
      t.references :genre, foreign_key: { to_table: :genres }, index: true
    end
  end
end

And a second one to populate that new table based on any existing 1:M records already in place in games. This preserves any genre stuff you might already have kicking around. The final action in the second migration is to remove what would then be the unnecessary lingering foreign key games_id from games:

# db/migrate/20220731162000_migrate_genre_data.rb
class MigrateGenreData < ActiveRecord::Migration[7.0]
  def self.up
    if column_exists?(:games, :genres_id)
      # Migrate any existing 1:M associations from the games table to the new associative table
      Game.joins('INNER JOIN genres ON games.genres_id = genres.id')
          .select('games.id AS game_id', 'genres.id AS genre_id')
          .each do |game_genre|
            GameGenre.create(game_id: game_genre.game_id, genre_id: game_genre.genre_id)
          end
      # Remove the 1:M column from games, starting with its index
      remove_index :games, name: 'index_games_on_genres_id'
      remove_column :games, :genres_id
      Game.reset_column_information
    end
  end

  def self.down
    unless column_exists?(:games, :genres_id)
      # Note that this should really have a singular name -- "genre" instead of "genres" -- it's set up
      # this way only to match how the original table was set up.
      add_reference :games, :genres, index: { name: 'index_games_on_genres_id' }
      Game.reset_column_information
      if Object.const_defined?('GameGenre')
        GameGenre.each do |game_genre|
          Game.find_by(id: game_genre.game_id)&.update(genres_id: game_genre.genre_id)
        end
      end
    end
  end
end

After successfully running these two migrations with bin/rails db:migrate then you can remove the belongs_to found in your Game model, and also if you have an inverse has_many found in Genre then it can be axed. The has_many would look something like this:

# If this exists in your Genre model then remove it:
has_many :games, foreign_key: :genres_id

Ultimately now your Game and Genre models can look like:

# app/models/game.rb
class Game < ApplicationRecord
  has_many :game_genres
  has_many :genres, through: :game_genres
end
# app/models/genre.rb
class Genre < ApplicationRecord
  has_many :game_genres
  has_many :games, through: :game_genres
end

And then you should be able to do this in a bin/rails c to see a list of games related to your first genre:

Genre.first.games

Let me know how you get on!

1 Like

Hello Lorin and thank you for your reply, I’ve done as you told me to, but I still need to add this association in my controller and view, if you still wanna help me Here’s what I have right now : games_controller.rb :

class GamesController < ApplicationController
  def index
    @games = Game.all
  end

  def show
    @game = Game.find(params[:id])
    @genres = @game.genres
  end

  def new
    @game = Game.new
  end

  def create
    @game = Game.new(game_params)

    if @game.save
      redirect_to @game
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    @game = Game.find(params[:id])
    @genres = Genre.all
  end

  def update
    @game = Game.find(params[:id])

    if @game.update(game_params)
      redirect_to @game
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private
  def game_params
    params.require(:game).permit(:name, :description, :steam, :website, :genres)
  end
end

edit.html.erb :

<h1>Modifier le jeu</h1>

<%= form_with model: @game do |form| %>
  <h1><%= form.text_field :name %></h1>

  <h2><%= form.label :description %></h2>
  <p><%= form.text_area :description %></p>

  <h2><%= form.label :steam %></h2>
  <p><%= form.text_field :steam %></p>

  <h2><%= form.label :website %></h2>
  <p><%= form.text_field :website %></p>

  <%= form.collection_check_boxes(:genres, Genre.order(:id), :id, :name) %>

  <%= form.submit %>
<% end %>

Much of the edit form is working, just not the check boxes. When you have a has_many :through then it’s not immediately obvious that amongst the methods automatically built out on the model, the ones that work along with check boxes are: genre_ids and genre_ids=. To make use of the special _ids methods, change your game_params to be:

def game_params
  params.require(:game).permit(:name, :description, :steam, :website, genre_ids: [])
end

also change your collection_check_boxes() line to refer to genre_ids like this:

<%= form.collection_check_boxes(:genre_ids, Genre.order(:id), :id, :name) %>

and then your check boxes will start working!

Bear in mind that when you click the submit button, it will attempt to use the :show page, and if you don’t have one built out yet then after seeing an error then you can just navigate back to the :edit page and the saved changes will show up. (And it’s good to also check out what happens in the console, you should see an UPDATE statement being run on your database, plus INSERTs and DELETEs for the game_genres table where the associations are stored.)

Fun times!

1 Like