In Rails, associations are a powerful way to define relationships between models in an intuitive and expressive manner.
Establishing these connections, makes working with the framework easier by reducing duplication or tedious operations with data that's naturally associated.
Let's explore what can associations do for us and why do they exist:
Why associations
The main reason associations exist in Rails is that, without them, we would have to write a lot of code to handle data that's associated with our records.
We would also have to worry about data integrity issues like orphaned records, that arise because of deleting a given record and forgetting or failing to delete associated data.
In Rails associations are declared using macros, which are methods that can generate or modify methods at runtime:
class User < ApplicationRecord
has_many :articles
end
This macro adds many methods to the user model that are useful to query and manipulate associated records.
These methods know how to query the database to return the associated Article
collection that belongs to the user and avoid us the need to query using Active Record:
user = User.first
articles = user.articles
articles = Article.where(user_id: user.id) ## Alternative to using associations
Moreover, we can pass the dependent: :destroy
argument to the association:
class User < ApplicationRecord
has_many :articles, dependent: :destroy
end
Which will destroy associated articles if we delete a given user. This saves us from having to write repetitive code every time we need to destroy records:
user = User.first
articles = Article.where(user_id: user.id)
articles.each do |article|
article.destroy
end
user.destroy
Types of associations in Rails
Rails provides six types of basic associations and some advanced associations to help us model our data layer to fit our needs.
Note that these associations are not exclusively related to Rails, but they are a convenient time saver and can also avoid
Let's start by understanding the belongs_to
association:
Belongs to
The belongs-to association establishes a relationship with another model, given that the model that establishes the belongs_to
relates to one instance of the associated model.
class Article < ApplicationRecord
belongs_to :user
end
In this case, we know that an article belongs to one user, which is the author.
By convention, and unless we define it otherwise, this association expects a user_id
foreign_key in the articles table.
It also makes the Article
instances “know” about the user that it belongs to, but it doesn't create the inverse relation unless we declare it in the User
class.
This means that, to allow users to be associated with multiple articles, we would need to declare a has_many
or has_one
association in the User
class depending on how we want to model our data.
Has one
The has_one
association is useful when we intend to declare that two models can only be related with one instance of each other.
For example, we might want to store the user's profile information in a separate profiles
table with a corresponding Profile
model.
However, a given user normally has only one profile, so we can use the has_one
in this case.
class User < ApplicationRecord
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :user
end
Setting both associations would give the User
access to the .profile
method, which would return a single Profile
instance that corresponds to the first profile associated with the user.
Note that declaring the association doesn't validate that a user can only create one profile, so we might want to add a validation to make sure that we cannot add multiple profiles for a user:
class Profile < ApplicationRecord
belongs_to :user
validates :user_id, uniqueness: true
end
This is a good start but enforcing uniqueness at the application level isn't enough to guarantee that no duplicate profile can be created at the database level.
To solve this, we can add an index with a migration:
class AddUniqueIndexToProfiles < ActiveRecord::Migration[8.0]
def change
add_index :profiles, :user_id, unique: true
end
end
Has Many
The has_many
association creates a one-to-many relationship with other models, meaning that a model can have zero or multiple instances of another model.
It's declared like this:
class Author < ApplicationRecord
has_many :books
end
It's implied that the Book
model's table will have a foreign key that references the author. By default, this key would match the model in singular: article_id
but we can customize that if it suits us.
Like stated before, many book related methods are defined by the has_many :books
macro. These methods allow us to build, create, query and manipulate the related records.
Just like before, if we would like to add referential integrity at the database level, we would have to add the foreign_key: true
when creating the books
table:
create_table :books do |t|
t.references :author, foreign_key: true
end
This will make sure that no book can be created without a valid author: if we try to associate a book with an author_id
that doesn't exist, we would meet with an error at the db level.
Has many through
The has_many :through
association sets up a many-to-many relationship using a join model, which relates two models via an intermediary.
For this example, we will model a user that can place orders within our application. At some point, we might want to retrieve every purchased item for a given user.
The has_many :through
association is helpful for a case like this. The association setup looks like this:
class User < ApplicationRecord
has_many :orders
end
Then, we set the associations in the Order
model:
class Order < ApplicationRecord
belongs_to :user
has_many :order_items
end
Conversely, the OrderItem
model would declare a belongs_to
:
class OrderItem < ApplicationRecord
belongs_to :order
end
Finally, we associate the users with the order items:
class User < ApplicationRecord
has_many :orders
has_many :order_items, through: :orders
end
If we find that the join model—the order item in this case—doesn't do anything beyond connecting the User
and Order
models, we can use a has_and_belongs_to_many
association, even though, in the long term, that's rarely the case, and we are usually better of using this type of association.
Has one through
The has_one :through
association establishes a one-to-one relationship with another model through an intermediary model.
It's very similar to the has_many :through
except we get access to one instance of other models through a third one.
In this example, each order item represents a product that has been purchased. And, because each product only has one brand, we can access the brand for it from the OrderItem
model.
To achieve this, we would have to set the associations like this:
class OrderItem < ApplicationRecord
belongs_to :product
has_one :brand, through: :product
end
Then, we would have to set the association between the Product
and the Brand
:
class Product < ApplicationRecord
belongs_to :brand
has_many :order_items
end
We would then be able to access the brand of order items using order_item.brand
which
Has and belongs to many
The has_and_belongs_to_many
association is very similar to the has_many :through
with one difference: it requires a join table but not a corresponding model and the join table is not required to have a primary key.
In the example shown above, we're modeling a learning platform where users can enroll in courses.
To define the has_and_belongs_to_many
associations, we don't need to define a Enrollment
model, just to have a join table that follows a convention: it should be the result of joining both model names in plural and alphabetical order.
Hence, we get the course_users
table.
Now, to declare the association, we need to add the has_and_belongs_to_many
macro to both models:
class User < ApplicationRecord
has_and_belongs_to_many :courses
end
class Course < ApplicationRecord
has_and_belongs_to_many :users
end
You might feel that the course_users
table is not very expressive, and you would probably be right, so we can rename the table to something like enrollments
:
![[has-and-belongs-to-many-custom-table-name.png]]
Now, we just need to specify the table name to the association using the join_table
argument:
class User < ApplicationRecord
has_and_belongs_to_many :courses, join_table: "enrollments"
end
class Course < ApplicationRecord
has_and_belongs_to_many :users, join_table: "enrollments"
end
Now, everything should work the same as before, but now we have a more meaningful join table.
As a note, if we want to create a join table without a primary key, we would have to use the create_join_table
method in the migration.
Consider that prescinding of a PK for this join table can give us minor performance gains and make the table more simple, but we have to put some thought before deciding to avoid regretting it in the future.
Customizing Associations
Rails associations can be customized if your database tables or model names do not correspond directly with the defaults.
This means that we can declare associations with models whose names don't exactly match the name of the association or that have table names that differ from the model's name.
For example, we could have a User
that acts as an author for articles without having to create a separate Active Record model:
class Article < ApplicationRecord
belongs_to :author, class_name: "User", foreign_key: :user_id
end
Using the class_name
we can tell the Article
which class we expect to relate to, and the foreign_key
lets the association know that we're expecting a user_id
on the articles table.
We can also customize the foreign_key
to match our association name by having an author_id
column that acts as a foreign key in the articles table and making that explicit in the association:
class Article < ApplicationRecord
belongs_to :author, class_name: "User", foreign_key: :author_id
end
Of course, these changes only make sense if the semantical gains are worth more to our codebase than deviating from Rails conventions.
Because of the presence of the foreign_key
in this customization, Active Record will not recognize the bidirectional association between the author and the article, which will lose us access to some association goodies.
That's why we can make the relation explicit using the inverse_of
to customize the association without losing any features:
class Article < ApplicationRecord
belongs_to :author, class_name: "User", foreign_key: :author_id, inverse_of: :article
end
Advanced associations
Rails provides us with three advanced associations besides the six basic ones we already covered.
These allow us to relate models in ways that are less intuitive but help us avoid duplication and let us better work with data when some specific scenarios arise:
Polymorphic associations
A polymorphic association, which gets its name from the OOP programming principle, establishes a relation between models that don't necessarily know about each other via their foreign keys.
It allows us to relate one model to multiple other models without explicitly defining the relation.
In the example above, a Bookmark
is a model that can belong to posts or products or, really, an indefinite number of things.
The Bookmark
model constructs the relation via the bookmarkable
association, which is an arbitrary name we give to things that can be bookmarked.
This means that, for example, a user can bookmark posts or products using a single bookmarks
table without the need to add hypothetical post_bookmarks
and product_bookmarks
tables and associating them with users.
They are very useful when we are confident that we will need our model to relate to other models in a generic way. Some good use cases for this type of association are:
- Comments
- Bookmarks
- Reactions
- Images
- Votes
- Tags
- Reviews
To declare this association in the example shown above, we first need to start with the polymorphic model:
class Bookmark < ApplicationRecord
belongs_to :user
belongs_to :bookmarkable, polymorphic: true
end
Then, we declare the inverse association for posts and products:
class Post < ApplicationRecord
has_many :bookmarks, as: :bookmarkable
end
class Product < ApplicationRecord
has_many :bookmarks, as: :bookmarkable
end
Here, the bookmarkable
object would be the one declaring the has_many
so, calling @bookmark.bookmarkable
would return a Post
instance when it's a post and a Product
otherwise.
And, products and posts would have access to the bookmarks
method which would return the bookmarks associated with them, which also contain the user for every instance.
As our application grows, we can add the has_many :bookmarks, as: :bookmarkable
to as many models as we require and things should work without many issues.
When adding a polymorphic association like this, it's a good idea to add an index to tie the type
and id
attributes of the polymorphic table:
class CreateBookmarks < ActiveRecord::Migration[8.0]
def change
create_table :bookmarks do |t|
t.bigint :bookmarkable_id
t.string :bookmarkable_type
t.timestamps
end
add_index :bookmarks, [:bookmarkable_id, :bookmarkable_type]
end
end
One last bit of advice: if you decide to change the class names of the bookmarked items, say you want to change from Post
to Article
you have to update the corresponding bookmarks
table to keep everything working as expected.
Composite primary keys
A composite primary is a primary key that's composed of multiple columns.
It means that, instead of being able to find a record with a single key like Product.find(4)
we would need to pass various attributes to retrieve a given product.
We can use these composite keys when we want to keep record integrity to a new level:
product = Product.find([1, "RCMM-1025"]) # => Find by id and SKU
If we wish to set a composite key when creating a migration, we pass the desired attributes to the primary_key
argument:
class CreateEnrollments < ActiveRecord::Migration[8.0]
def change
create_table :enrollments, primary_key: [:course_id, :user_id] do |t|
t.integer :course_id
t.integer :user_id
end
end
end
Now, we need to specify our model to use a composite PK:
class Enrollment < ApplicationRecord
self.primary_key = [:course_id, :user_id]
belongs_to :course
belongs_to :user
end
However, before deciding whether using a composite PK is adequate for your use case, consider that they can result in performance degradation and increased application logic.
Self Joins
The self-join association is one where the foreign_key
is located within the same table of the model that declares it.
They are useful to model relationships between different instances of a same class, especially when we need some sort of hierarchy
For example, if we wanted to model a category hierarchy where items belong to categories or subcategories, we could achieve it using a single Category
table with self-joins:
class Category < ApplicationRecord
belongs_to :parent, class_name: "Category", optional: true
has_many :subcategories, class_name: "Category", foreign_key: "parent_id"
end
The migration would look something like this:
class CreateCategories < ActiveRecord::Migration[8.0]
def change
create_table :categories do |t|
t.string :name, null: false
t.references :parent, foreign_key: { to_table: :categories }, index: true, null: true
t.timestamps
end
add_index :categories, [:name, :parent_id], unique: true
end
end
By declaring an optional parent_id
field, we could consider categories with no parent_id
as root categories, and we could model a tree structure where categories could have children, grandchildren, fathers, grandfathers, and so on.
class Category < ApplicationRecord
belongs_to :parent, class_name: "Category", optional: true
has_many :subcategories, class_name: "Category", foreign_key: "parent_id"
scope :root_categories, -> { where(parent_id: nil) }
def root?
parent_id.nil?
end
def ancestors
return [] if root?
## Recursive traversal of ancestors returning
## an array with the ancestor list
parent.ancestors + [parent]
end
def descendants
# Recursive traversal of descendants that returns a
# flattened array with the Category instances
subcategories.map { |subcat| [subcat] + subcat.descendants }.flatten
end
end
Now, items can declare a belongs_to :category_id
and be related to a category without knowing specifically about the hierarchy.
Other associations
Rails offers access to other associations like Single Table Inheritance and Delegated Types.
Even though these associations are very useful for some use cases, they escape the scope of this article. We will talk about them in future articles so you can learn more about them and how to use them in your applications.
Meanwhile, you can learn about delegated types in the article we wrote about building the kanban board feature for Avo.
Stay in touch to learn more about advanced associations in Rails.
Summary
Rails associations transform how you work with related data, making your code more expressive and maintainable. By understanding the different types of associations and their options, you can model complex relationships with ease. Whether you're building a simple blog or a complex application with intricate data relationships, mastering associations is essential for effective Rails development.
The true power of associations lies not just in the syntax they provide, but in how they encourage you to think about your data in terms of relationships. This mental model aligns closely with how we naturally think about information, making your code not only more efficient, but also more intuitive to write and understand.