Rails 6 has added a new feature that adds delegated_type
to Active Record. In this blog post, we are going to learn how to use delegated_type
in our Active Record model using a real-life project and also discuss the benefits of using it.
Project
Implement a School Management System. The system will have Users
with different profiles. eg: Student, Teacher, Department Head, Support Staff.
Solution
We are going to explore various possible solutions before jumping on to delegated_type
.
1. Single-table inheritance (STI)
Single Table Inheritance as the name implies combines all the fields of various user profile and stores them in a single mega table. If a field is not needed for a specific user profile, its value will be nil
.
This is how the resultant mega table would look like.
id | name | record_type | grade | department_name | service_category |
---|---|---|---|---|---|
1 | John | student | 5 | ||
2 | Doe | student | 1 | ||
3 | Borg | teacher | Math | ||
4 | Eric | teacher | English | ||
5 | Anna | support_staff | Cleaning | ||
6 | Venice | support_staff | Admin |
Issues
- This table will be sparsely filled with a lot of space wasted if each user profile has lots of divergence and little in common.
2. Abstract Class
In this method, we would use an abstract class to scope out the shared code used between the various user profile.
class User < ApplicationRecord
self.abstract_class = true
def say_hello
"Hello #{name}"
end
end
#Schema: students[ id, name, grade ]
class Student < User
end
#Schema: teachers[ id, name, department ]
class Teacher < User
end
Example
> Student.create(name: "John", grade: 1)
> Student.first.say_hello
"Hello John"
> Teacher.create(name: "Lisa", department: "English")
> Teacher.first.say_hello
"Hello Lisa"
Issues
- Tables are not normalized
- It's impossible to implement pagination of combined
Users
. Even if one had to try, it would mean querying two tables simultaneously with no proper limits and offset.
3. Multiple tables with an association
Here we use one parent table to extract out all the common table attributes and use Active Record association to refer profile-specific data
#Schema: users[ id, name ]
class User < ApplicationRecord
has_one :student_profile, class_name: "Student"
has_one :teacher_profile, class_name: "Teacher"
enum user_type: %i(student teacher)
def say_hello
"Hello #{name}"
end
def profile
return student_profile if self.student?
return teacher_profile if self.teacher?
end
end
#Schema: teachers[ id, department, user_id ]
class Teacher < ApplicationRecord
end
#Schema: students[ id, grade, user_id ]
class Student < ApplicationRecord
end
Example
> User.where(name: "John").first.profile.grade
1
> User.where(name: "John").say_hello
"Hello John"
> User.where(name: "Lisa").first.profile.department
"English"
> User.where(name: "Lisa").say_hello
"Hello Lisa"
4. Active Record delegated_type
Using Active Record with delegated_type
would be similar to multiple table with association, but it would abstract away all the conditional code giving us a neat slate. We have a parent table with common attributes User
and child table containing necessary profile information Student
, Teacher
.
#Schema: users[ id, name, profilable_type, profilable_id ]
class User < ApplicationRecord
delegated_type :profilable, types: %w[ Student Teacher Support ]
def say_hello
"Hello #{name}"
end
end
#Schema: teachers[ id, department ]
class Teacher < ApplicationRecord
include Profilable
end
#Schema: teachers[ id, grade ]
class Student < ApplicationRecord
include Profilable
end
module Profilable
extend ActiveSupport::Concern
included do
has_one :user, as: :profilable, touch: true
end
end
Creating a new Record
> User.create! profilable: Student.new(grade: 5), name: "John"
> User.create! profilable: Teacher.new(department: 'Math'), name: "Lisa"
Querying capability
> User.where(name: "John").first.profilable
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."name" = ? ORDER BY "users"."id" ASC LIMIT ? [["name", "John"], ["LIMIT", 1]]
Student Load (0.1ms) SELECT "students".* FROM "students" WHERE "students"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
=> #<Student id: 2, grade: 5>
> User.where(name: "Lisa").first.profilable
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."name" = ? ORDER BY "users"."id" ASC LIMIT ? [["name", "Lisa"], ["LIMIT", 1]]
Teacher Load (0.2ms) SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<Teacher id: 1, department: "Math">
> Teacher.where(department: "Math").count
(0.2ms) SELECT COUNT(*) FROM "teachers" WHERE "teachers"."department" = ? [["department", "Math"]]
=> 1
> Student.where(grade: 5).first.user.name
Student Load (0.1ms) SELECT "students".* FROM "students" WHERE "students"."grade" = ? ORDER BY "students"."id" ASC LIMIT ? [["grade", 5], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."profilable_id" = ? AND "users"."profilable_type" = ? LIMIT ? [["profilable_id", 2], ["profilable_type", "Student"], ["LIMIT", 1]]
=> "John"
You can notice that delegated_type
is similar to multiple table with association, however, it abstracts out the
implementation details using rails magic. Pagination would now be possible for combined User
entity
Top comments (0)