goku
goku

Reputation: 1207

Polymorphic associations break in rails 6.1

I have inherited some legacy rails code with workarounds around standard polymorphic associations. These used to work up until rails 5. Now I am trying to upgrade to rails6.1 and they break and I have no clue as to how to fix them.

Problem: We have following 2 tables:

  1. posts

    • id
    • text
    • ...
  2. comments

    • id
    • commentable_id
    • commentable_type
    • ...

A post has 3 types of comments -

  1. Admin comment
  2. Anonymous comment
  3. User comment

We have modeled this as polymorphic relationship as follows:

# comment.rb
class Comment < ActiveRecord::Base
  belongs_to :commentable, polymorphic: true
end
# post.rb
class Post < ActiveRecord::Base
  has_many :comments, as :commentable, dependent: :destroy, inverse_of: :post

  has_many :admin_comments, -> { where commentable_type: "AdminComment" },
    class_name: 'Comment', foreign_key: :commentable_id,
    foreign_type: :commentable_type, dependent: :destroy, inverse_of: :post

  has_many :user_comments, -> { where commentable_type: "UserComment" },
    class_name: 'Comment', foreign_key: :commentable_id,
    foreign_type: :commentable_type, dependent: :destroy, inverse_of: :post

  has_many :anonymous_comments, -> { where commentable_type: "AnonymousComment" },
    class_name: 'Comment', foreign_key: :commentable_id,
    foreign_type: :commentable_type, dependent: :destroy, inverse_of: :post
end

With this setup, I could fetch either all comments by doing -

p = Post.find(123)
p.comments

or fetch comments by specific type of user -

p = Post.find(123)
p.user_comments
p.anonymous_comments

With rails 6.1 this setup does not work. When trying to run rails console It fails with error:

ruby2.7.x/lib/ruby/gems/2.7.0/gems/activesupport-6.1.4.1/lib/active_support/core_ext/hash/keys.rb:52:in `block in assert_valid_keys': Unknown key: :foreign_type. Valid keys are: :class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :autosave, :before_add, :after_add, :before_remove, :after_remove, :extend, :counter_cache, :join_table, :index_errors, :ensuring_owner_was (ArgumentError)

After looking for potential answers online, the closest I could get was by adding as: :commentable to each association like -

  has_many :user_comments, -> { where commentable_type: "UserComment" },
    class_name: 'Comment', foreign_key: :commentable_id,
    foreign_type: :commentable_type, dependent: :destroy, inverse_of: :post, as: :commentable

But this does not get me correct result set. It generates sql as follows -

SELECT comments.* FROM comments WHERE comments.commentable_id = 123 AND comments.commentable_type = 'Post' AND comments.commentable_type = 'UserComment'

without as: :commentable it would generate sql as -

SELECT comments.* FROM comments WHERE comments.commentable_id = 123 AND comments.commentable_type = 'UserComment'

I am kinda lost and would appreciate any help!

Thanks!

Upvotes: 3

Views: 1222

Answers (2)

Harry Geo
Harry Geo

Reputation: 1173

EDIT After reading the documentation carefully, this should work for has_one association types and not has_many

Rails 6.1 added Delegated Types that can be used to achieve what you want.

Try the following

# comment.rb
class Comment < ActiveRecord::Base
  belongs_to :commentable, polymorphic: true
end
# post.rb
class Post < ActiveRecord::Base
  has_many :comments, as :commentable, dependent: :destroy, inverse_of: :post
  delegated_type :commentable, types: %w[AdminComment UserComment AnonymousComment], dependent: :destroy
end

Upvotes: 0

max
max

Reputation: 102222

This looks like an extremely confused and misguided attempt at a combination of Single Table Inheritance and a polymorphic assocations with no understanding of how either works and I doubt the code actually worked as intended in earlier versions.

STI

In a STI setup you use the type inheritance column (type by default) to specify what class ActiveRecord will load by when retrieving the rows from the database. That means your comments table should have a string column named type containing values such as UserComment and AdminComment.

class Comment < ActiveRecord::Base
  belongs_to :commentable, polymorphic: true
end

class UserComment < Comment 
end

class AdminComment < Comment 
end

# ...

When you setup a has many assocation pointing to a STI model you should set the condition on the type column of the table - not commentable_type:

class Post < ActiveRecord::Base
  has_many :comments
  has_many :user_comments, -> { where(type: 'UserComment') }    
  has_many :admin_comments, -> { where(type: 'AdminComment') }
  # ...
end
irb(main):002:0> puts Post.joins(:user_comments).to_sql  
SELECT "posts".* FROM "posts" 
INNER JOIN "comments" 
ON "comments"."type" = 'UserComment' 
AND "comments"."post_id" = "posts"."id"  

Polymorhic assocations

Polymorphic assocations are really just a dirty cheat to get around the Object Relational Impedience Missmatch problem. By using a integer column together with a varchar column you can create a single assocation that points to any table - this would be for example attaching comments to posts, videos, images or even other comments. Thats what the commentable_type column should actually be containing.

To change the assocations from the previous example to be polymorphic you just have to add the as: option to tell Rails what belongs_to assocation to look for:

class Post < ActiveRecord::Base
  has_many :comments, as: :commentable
  has_many :user_comments, 
    -> { where(type: 'UserComment') }, 
    as: :commentable
  has_many :admin_comments, 
    -> { where(type: 'AdminComment') },  
    as: :commentable
end

Otherwise it will just assume that you're looking for an assocation named post on the other end - and as: tells ActiveRecord that its a polymorphic assocation:

irb(main):002:0> puts Post.joins(:user_comments).to_sql  
SELECT "posts".* FROM "posts" 
INNER JOIN "comments" ON "comments"."commentable_type" = 'Post' 
AND "comments"."type" = 'UserComment' 
AND "comments"."commentable_id" = "posts"."id"    

You don't have to configure the foreign_key, foreign_type or class_name option (this one was dead wrong) as it all can be derived from the name.

Upvotes: 1

Related Questions