Search code examples
ruby-on-railsassociationspolymorphic-associations

Rails two polymorphic associations in one model


I have the following models: Game, HighSchoolTeam, and ClubTeam. I want Game two have a team_one and a team_two field, with each field referring to a HighSchoolTeam or a ClubTeam.

In HighSchoolTeam and ClubTeam I have has_many :games, as: :teamable. In Game I would like to do something like the following...

class Game < ApplicationRecord
  belongs_to :team_one, polymorphic: true, class_name: "Teamable"
  belongs_to :team_two, polymorphic: true, class_name: "Teamable"
end

...but the class_name: "Teamable part doesn't seem to work.


Edit:

schema.rb

ActiveRecord::Schema.define(version: 2019_12_24_011346) do
  ...
  create_table "club_teams", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

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

  create_table "games", force: :cascade do |t|
    t.bigint "tournament_id", null: false
    t.string "team_one_type", null: false
    t.bigint "team_one_id", null: false
    t.string "team_two_type", null: false
    t.bigint "team_two_id", null: false
    t.bigint "field_id", null: false
    t.date "date"
    t.datetime "start_time"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["field_id"], name: "index_games_on_field_id"
    t.index ["team_one_type", "team_one_id"], name: "index_games_on_team_one_type_and_team_one_id"
    t.index ["team_two_type", "team_two_id"], name: "index_games_on_team_two_type_and_team_two_id"
    t.index ["tournament_id"], name: "index_games_on_tournament_id"
  end

  create_table "high_school_teams", force: :cascade do |t|
    t.string "school_name"
    t.string "team_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

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

  add_foreign_key "games", "fields"
  add_foreign_key "games", "tournaments"
end

game.rb

class Game < ApplicationRecord
  belongs_to :tournament
  belongs_to :team_one, polymorphic: true
  belongs_to :team_two, polymorphic: true
  belongs_to :field, optional: true
end

high_school_team.rb

class HighSchoolTeam < ApplicationRecord
  has_many :players
  has_many :games, as: :teamable, dependent: :destroy

  def name
    self.school_name
  end
end

club_team.rb

class ClubTeam < ApplicationRecord
  has_many :players
  has_many :games, as: :teamable, dependent: :destroy
end

console output

code/scout-db [master●] » rails c --sandbox
Running via Spring preloader in process 48525
Loading development environment in sandbox (Rails 6.0.1)
Any modifications you make will be rolled back on exit

WARNING: This version of ruby is included in macOS for compatibility with legacy software.
In future versions of macOS the ruby runtime will not be available by
default, and may require you to install an additional package.

irb(main):001:0> game = Game.new({ team_one_id: "high-school-team-2", team_one_type: "HighSchoolTeam", team_two_id: "club-team-2", team_two_type: "ClubTeam" })
   (0.2ms)  BEGIN
=> #<Game id: nil, tournament_id: nil, team_one_type: "HighSchoolTeam", team_one_id: 0, team_two_type: "ClubTeam", team_two_id: 0, field_id: nil, date: nil, start_time: nil, created_at: nil, updated_at: nil>
irb(main):002:0> game.team_one_id
=> 0
irb(main):003:0> game.save
   (0.3ms)  SAVEPOINT active_record_1
  HighSchoolTeam Load (0.4ms)  SELECT "high_school_teams".* FROM "high_school_teams" WHERE "high_school_teams"."id" = $1 LIMIT $2  [["id", 0], ["LIMIT", 1]]
  ClubTeam Load (0.3ms)  SELECT "club_teams".* FROM "club_teams" WHERE "club_teams"."id" = $1 LIMIT $2  [["id", 0], ["LIMIT", 1]]
   (0.4ms)  ROLLBACK TO SAVEPOINT active_record_1
=> false
irb(main):004:0> game.errors.full_messages.inspect
=> "[\"Tournament must exist\", \"Team one must exist\", \"Team two must exist\"]"

(2, Syosset, Braves, 2019-12-31 01:07:41.367913, 2019-12-31 01:07:41.367913) exists in the high_school_teams table and (2, Foobars, 2019-12-31 01:07:52.697821, 2019-12-31 01:07:52.697821) exists in the club_teams table.


Solution

  • Of course class_name: "Teamable" does not work as the whole point of a polymorphic association is that the class that the class (and more importantly target table) of the association is dynamic. Its not needed either.

    A polymorphic association uses a separate association_name_type string column which contains the class name which its associated with.

    Given the following data:

    | id | team_one_id | team_one_type # ...
    ----------------------------------
    1    |  1          | "HighSchoolTeam"
    2    |  2          | "ClubTeam"
    3    |  3          | "HighSchoolTeam"
    

    When you do Game.find(1).team_one Rails knows to use the HighSchoolTeam class and join the high_school_teams table. But it needs to pull the rows out before it can make the connection and of course your database knows nothing about the relation and won't maintain referential integrity.

    So all you need is:

    class Game < ApplicationRecord
      belongs_to :team_one, polymorphic: true
      belongs_to :team_two, polymorphic: true
    end
    

    And make sure you have team_one_type and team_two_type string columns on the games table.