Brandon C
Posted on February 16, 2023
Ruby on Rails allows for model and table associations on a database such as sqlite3 through a "convention over configuration" framework design. Lets say I have three tables: users
and another table named products
and the last is orders
, with the relationships between these tables is that a user
has many products
and many orders
, a product
belongs to a user and has many orders
, and an order
belongs to a user
and product
. The model files and migration files for creating the tables would look like this:
users -< products
user-< orders >- products
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :username
t.timestamps
end
end
end
...
class CreateProducts < ActiveRecord::Migration[7.0]
def change
create_table :products do |t|
t.string :name
t.belongs_to :user, null: false, foreign_key: true
t.timestamps
end
end
end
...
class CreateOrders < ActiveRecord::Migration[7.0]
def change
create_table :orders do |t|
t.integer :quantity
t.timestamps
end
end
end
#separate model files
class User < ApplicationRecord
has_many :products
has_many :orders
end
...
class Product < ApplicationRecord
belongs_to :user
has_many :orders
end
..
class Order < ApplicationRecord
belongs_to :product
belongs_to :user
end
Running the migration creates three tables where the foreign key user_id
is implicitly given to any product
row entry in the products
table, an order
entry holds foreign keys of user_id
and product_id
. The reason why the foreign key is user_id
in products
is because Active Record attaches "_id" to the name of the model being referenced. belongs_to
can be interchanged with references
in the products
migration file. Thanks to Active Record providing us with class methods and instance methods for defined associations, an example of accessing created entries through the console, model or controller could look like this:
>user = User.first
>user.products
=>#gives list of products instances associated to user
>user.products.first
#or
>User.first.products.first
=>#<Product id: 1, name: "Potted Plant", user_id: 1>
>buyer = User.last
>buyer.orders.first
=>#<Order id: 1, quantity: 2, product_id: 1, user_id: 2>
What we're trying to convey here is that users can put products up for sale and other users can buy them through orders. Users can be sellers of products and also buyers through the orders join table. What if we wanted to change the name of the association and foreign key to make the association more descriptive? We want the user to be a "seller" of a product and also be "buyers" of other products through making orders. We can change the name of the user
foreign key by using additional options for migrations, models and serializers. First, we can go back to the migrations and start over from the column descriptions either by rolling back, deleting the database, or altering the table columns.
class CreateProducts < ActiveRecord::Migration[7.0]
def change
create_table :products do |t|
t.string :name
t.belongs_to :seller, null: false, foreign_key: {to_table: :users}
t.timestamps
end
end
end
...
class CreateOrders < ActiveRecord::Migration[7.0]
def change
create_table :orders do |t|
t.integer :quantity
t.belongs_to :product, null: false, foreign_key: true
t.belongs_to :buyer, null: false, foreign_key: {to_table: :users}
t.timestamps
end
end
end
In these migrations, we are changing the foreign key table reference from "user" to "buyer" in the orders
table and "seller" in the products
table. Using the references buyer
and seller
will generate buyer_id
and seller_id
as the foreign keys. We also have to use the to_table
option to specify what the original table association reference is since putting seller
and buyer
as references will cause Active Record to look for the tables sellers
and buyers
which don't exist. In our case the original name of the table being used in both migration cases is users
, so the foreign keys will be buyer_id
and seller_id
which are made by deriving the primary id key from the users
table. The next is the models:
class User < ApplicationRecord
has_many :products, foreign_key: :seller_id
has_many :orders, foreign_key: :buyer_id
has_many :bought_products, through: :orders, source: :product
end
...
class Product < ApplicationRecord
belongs_to :seller, class_name: 'User'
#or belongs_to :user, foreign_key: :seller_id
has_many :orders
has_many :buyers, through: :orders
#or has_many :users, through: :orders
end
...
class Order < ApplicationRecord
belongs_to :product
belongs_to :buyer, class_name: 'User'
#or belongs_to :user, foreign_key: :buyer_id
end
In User
, we specify the name of the foreign key provided to the associated models, so the id of User
is given to Product
as seller_id
and given to Order
as buyer_id
. We also have to specify a has_many "through" relationship to describe products bought by orders so we can distinguish the associated class with a name like "bought_products" while we use source:
to point to the original name of the associated class, which is also products
. When we use the instance method user.products
, it will show us the products sold by the user and when we use user.bought_products
, it will show us the products bought by the user through order.
>user = User.last
=>#<User: id: 2, username: "Richard">
>user.orders.first
=>#<Order: id: 1, quantity: 2, product_id: 1, buyer_id: 2>
>user.orders.first.product
=>#<Product: id: 1, name: "Potted Plant", seller_id: 1>
>user.bought_products.first
=>#<Product: id: 1, name: "Potted Plant", seller_id: 1>
#product bought by user with ".bought_products"
>user.products.first
=>#<Product:id: 2, name: "Tasty Bannana", seller_id: 2>
#product sold by user with ".products", note the matching User id and seller_id
For Order
and Product
, we can use two ways to describe the belongs_to
macro association.
The first macro is using belongs_to :seller/:buyer
which causes Active Record to assume the implicit foreign key as seller_id
and buyer_id
. sellers
and buyers
have no associated model so we have to use the class_name
option to point out what model is supposed to be used for seller
and buyer
, which is 'User'. Doing this means that Active Record also provides the association instance methods as .seller
and .buyer
instead of .user
for instances of product
and order
.
>Product.first
=>#<Product: id: 1,name: "Potted Plant", seller_id: 1>
>Product.first.seller
=>#<User: id: 1, username: "Alan">
>Order.first
=>#<Order: id: 1, quantity: 2, product_id: 1, buyer_id: 2>
>Order.first.buyer
=>#<User: id: 2, username: "Richard >
The second macro is using belongs_to :user
which causes Active Record to recognize the class model User
but requires using foreign_key: :buyer_id/:seller_id
to specify what foreign key name is being derived from the User
model. This will retain the association instance methods provided as .user
for product
and order.
>Product.first
=>#<Product: id: 1,name: "Potted Plant", seller_id: 1>
>Product.first.user
=>#<User: id: 1, username: "Alan">
>Order.first
=>#<Order: id: 1, quantity: 2, product_id: 1, buyer_id: 2>
>Order.first.user
=>#<User: id: 2, username: "Richard >
We could also rewrite the migrations and model associations to maintain the user_id
foreign key in order
and product
while keeping the buyer
and seller
instance method associations. This can be done by keeping the migration files unchanged from what we originally had.
#migrations
class CreateProducts < ActiveRecord::Migration[7.0]
def change
create_table :products do |t|
t.string :name
t.belongs_to :user, null: false, foreign_key: true
t.timestamps
end
end
end
class CreateOrders < ActiveRecord::Migration[7.0]
def change
create_table :orders do |t|
t.integer :quantity
t.belongs_to :product, null: false, foreign_key: true
t.belongs_to :user, null: false, foreign_key: true
t.timestamps
end
end
end
...
#models
class User < ApplicationRecord
has_many :products
has_many :orders
has_many :bought_products, through: :orders, source: :product
end
class Product < ApplicationRecord
belongs_to :seller, class_name: 'User', foreign_key: :user_id
has_many :orders
has_many :buyers, through: :orders
end
class Order < ApplicationRecord
belongs_to :product
belongs_to :buyer, class_name: 'User', foreign_key: :user_id
end
>user = User.last
=>#<User: id: 2, username: "Richard">
>user.orders.first
=>#<Order: id: 1, quantity: 2, product_id: 1, user_id: 2>
>user.orders.first.product
=>#<Product: id: 1, name: "Potted Plant", user_id: 1>
>user.bought_products.first
=>#<Product: id: 1, name: "Potted Plant", user_id: 1>
>user.products.first
=>#<Product:id: 2, name: "Tasty Bannana", user_id: 2>
>product = Product.first
=>#<Product: id: 1, name: "Potted Plant", user_id: 1>
>product.seller
=>#<User: id: 1, username: "Alan">
>product.buyers.first
=>#<User: id: 2, username: "Richard">
If we use a serializer, we also have to set the association there depending on the class association we specified in the model. How we choose to define the model association will have an effect on the default json provided:
class ProductSerializer < ActiveModel::Serializer
attributes :id, :name, :seller_id
belongs_to :seller
#or belongs_to :user
end
class OrderSerializer < ActiveModel::Serializer
attributes :id, :quantity, :buyer_id
belongs_to :buyer
#or belongs_to :user
end
class UserSerializer < ActiveModel::Serializer
attributes :id, :username
has_many :products
has_many :orders
has_many :bought_products, through: :orders, source: :products
end
#using belongs_to :buyer/:seller in serializer and model
#get request for product
{
"id": 1,
"name": "Potted Plant",
"seller_id": 1,
"seller": {
"id": 1,
"username": "Alan"
}
}
#get request for order
{
"id": 1,
"quantity": 2,
"buyer_id": 2,
"buyer": {
"id": 2,
"username": "Richard"
}
}
#using belongs_to :user in serializer and model
#get request for product
{
"id": 1,
"name": "Potted Plant",
"seller_id": 1,
"user": {
"id": 1,
"username": "Alan"
}
}
#get request for order
{
"id": 1,
"quantity": 2,
"buyer_id": 2,
"user": {
"id": 2,
"username": "Richard"
}
}
#get request for user index with include: ['orders', 'orders.product', 'products', 'bought_products']
{
"id": 1,
"username": "Alan",
"products": [
{
"id": 1,
"name": "Potted Plant",
"seller_id": 1
}
],
"orders": [],
"bought_products": []
},
{
"id": 2,
"username": "Richard",
"products": [
{
"id": 2,
"name": "Tasty Bannana",
"seller_id": 2
}
],
"orders": [
{
"id": 1,
"quantity": 2,
"buyer_id": 2,
"product": {
"id": 1,
"name": "Potted Plant",
"seller_id": 1
}
}
],
"bought_products": [
{
"id": 1,
"name": "Potted Plant",
"seller_id": 1
}
]
}
If we set up a custom serializer, we can show the associations involved with a get request and cut down the unneeded attributes for the json return:
class OrderSerializer < ActiveModel::Serializer
attributes :id, :quantity
belongs_to :buyer
belongs_to :product, serializer: OrderProductSerializer
end
class OrderProductSerializer < ActiveModel::Serializer
attributes :id, :name
belongs_to :seller
end
#get request for an order with include: ['buyer', 'product', 'product.seller']
{
"id": 1,
"quantity": 2,
"buyer": {
"id": 2,
"username": "Richard"
},
"product": {
"id": 1,
"name": "Potted Plant",
"seller": {
"id": 1,
"username": "Alan"
}
}
}
Even though Ruby on Rails is a convention over configuration framework, this is just one way to deviate from its implicit based design by using the options that it provides us.
Done with Ruby 2.7.4
References
-https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_reference
-https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-belongs_to
-https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many
-https://flatironschool.com/
Posted on February 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.