Active Record delegated_type
Tags: rails, ruby
Published: 2020-09-30 16:30:32

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.

```ruby

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
```bash

> 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
  
```ruby
#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
```bash
> 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`. 

```ruby
#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

```ruby
> User.create! profilable: Student.new(grade: 5), name: "John"
> User.create! profilable: Teacher.new(department: 'Math'), name: "Lisa"
```

Querying capability

```bash
> 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
 
## Further Reading
1. [Rails Antipattern](https://acuments.com/10-rails-antipatterns-to-avoid-writing-well-reasoned-code.html)