July 11, 2015

Rails Join Table with Extra Attributes

The problem: What happens when a user can "own" other user's articles too, but you also need to save attributes unique to that user?

Rails Join Table with Extra Attributes

The majority of Rails model relationships are relatively straightforward. A user has many articles and an article belongs to a user.

The problem: What happens when a user can "own" other user's articles too, but you also need to save attributes unique to that user?

If you've ever taken a database course (if you haven't, you should!), you will probably remember many-to-many relationships and join tables. This is exactly what we will be setting up, but extending functionality to access additional attributes on these join table objects while still making them behave like the object they're supposed to join to.

Setting up the Project

In any directory or your choosing, set up a new project with rails new JoinTutorial. From within the new project directory, execute the following:

rails g migration CreateUser username:string
rails g migration CreateArticle title:string text:string is_favorited:boolean user_id:integer
rails g migration CreateUserArticle user_id:integer article_id:integer is_favorited:boolean

These three migrations set up a User, Article, and UserArticle. In this example project, a user will own many articles, but that same user will also be able to "save" other user's articles. On top of this, a user has the choice of favoriting either their own articles or articles they've saved via the is_favorited attribute.

Creating the Model Relationships

Next, in app/models create user.rb with the following:

# app/models/user.rb
class User < ActiveRecord::Base 
  has_many :articles 
  has_many :user_articles 
  has_many :saved_articles, :through => :user_articles 
 
  def all_articles 
    articles + saved_articles 
  end 
end 

As you can see, there are three relationships set up here and a method. A user has many articles through has_many :articles, as well as many user_articles through has_many :user_articles. The final has_many allows us to call @user.saved_articles and return a collection of articles the user has saved.

The lone method is simply for convenience. On a user's home page you might want to list all of the articles they have both posted and saved.

Next, you'll want to create article.rb in the same directory like so:

# app/models/article.rb
class Article < ActiveRecord::Base
  belongs_to :user
  has_many :user_articles
end

And finally, the join table object that links the two together, user_article.rb:

# app/models/user_article.rb
class UserArticle < ActiveRecord::Base
  belongs_to :user
  belongs_to :saved_article, class_name: 'Article', foreign_key: :article_id
end

Notice it belongs_to :saved_article instead of :article, even though it references the Article class.

Understanding the Relationships

So you have the code, but what is it actually doing?. The most confusing part of all of this is the :saved_article. Because a user can have both their own articles and the articles of others, it becomes important to distinguish the two with different names. has_many :saved_articles, :through => :user_articles says, in effect, that User has the "saved article" that UserArticle belongs to. If we used :articles, it would be indistinguishable from the first has_many :articles in user.rb

Testing the Relationships

First, fire up a rails console with rails c.

Next, create some example objects with the following:

User.create(username: "example_user1")
User.create(username: "example_user2")
Article.create(title: "ExampleArticle1", text: "ExampleText1", user_id: 1, is_favorited: false)
Article.create(title: "ExampleArticle2", text: "ExampleText2", user_id: 2, is_favorited: false)
UserArticle.create(user_id: 2, article_id: 1, is_favorited: true)

This sets up two users and two articles, each article owned by it's respective user. Note that they did not favorite their own articles. The final line is what would happen if a user saved another user's article. In this case example_user2 saved example_user1's article and favorited it.

Test out the relationships with the following commands and you should see output like so:

User.find(1).articles
=> #<ActiveRecord::Associations::CollectionProxy [#<Article id: 1, title: "ExampleArticle1", text: "ExampleText1", user_id: 1, is_favorited: false>]>

User.find(2).articles
=> #<ActiveRecord::Associations::CollectionProxy [#<Article id: 2, title: "ExampleArticle2", text: "ExampleText2", user_id: 2, is_favorited: false>]>

User.find(2).all_articles
=> [#<Article id: 2, title: "ExampleArticle2", text: "ExampleText2", user_id: 2, is_favorited: false>, #<Article id: 1, title: "ExampleArticle1", text: "ExampleText1", user_id: 1, is_favorited: false>]

Oops. example_user2's second article in all_articles isn't favorited. Instead of using the is_favorited attribute from the join table, it's using the one from the Article table itself.

In user.rb, replace has_many :saved_articles, :through => :user_articles with has_many :saved_articles, -> { select('articles.*, user_articles.is_favorited as is_favorited') }, :through => :user_articles.

Open up the console and try it again:

User.find(2).all_articles
=> [#<Article id: 2, title: "ExampleArticle2", text: "ExampleText2", user_id: 2, is_favorited: false>, #<Article id: 1, title: "ExampleArticle1", text: "ExampleText1", user_id: 1, is_favorited: true>]

It works! So what happened?

Rails 4 supports scoping in its has_many relationships. By adding -> { select('articles.*, user_articles.is_favorited as is_favorited') }, we specified that Rails should use all of the attributes in the Article table except for is_favorited, which is taken from the join table UserArticle.

Final Thoughts

This is just a taste of manipulating SQL under the hood of ActiveRecord. has_many relationships in the model file can get far, far more complex, but that is beyond the scope here. Experiment with model relations!