Active Record delegated type

By Ronak Gothi, Lalit kumar On September 30 2020

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

  1. 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.

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

  1. Tables are not normalized
  2. 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

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" 


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.

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:

  1. Tables are normalized which is more efficient in the long run. Despite the fact that STI stores NULL values for fields not used, technically they still occupy tiny bit of space. This may lead to issues like fragmentation, bad query performance.
  2. It gives a clear mental model. It's much easier to query with limit/offset capability on Student , Teacher and Users with delegated_type.