By Ronak Gothi
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.
Implement a School Management System. The system will have Users
with different profiles. eg: Student, Teacher, Department Head, Support Staff.
We are going to explore various possible solutions before jumping on to delegated_type
.
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
In this method, we would use an abstract class to scope out the shared code used between the various user profile.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
1
2
3
4
5
6
7
> 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
Users
. Even if one had to try, it would mean querying two tables simultaneously with no proper limits and offset. Here we use one parent table to extract out all the common table attributes and use Active Record association to refer profile-specific data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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
1
2
3
4
5
6
7
8
9
> 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"
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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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: students[ 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
1
2
> User.create! profilable: Student.new(grade: 5), name: "John"
> User.create! profilable: Teacher.new(department: 'Math'), name: "Lisa"
Querying capability
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> 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. Pagination would now be possible for combined User
entity
Advantages:
NULL
values for fields not used, technically they still occupy tiny bit of space.
This may lead to issues like fragmentation, bad query performance.Student
, Teacher
and
Users
with delegated_type.