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!