Recursive bi-directionnal many-to-many relationship
We recently reworked a big part of Drawbotics' backoffice, and decided to implement a production workflow close to the logic of a Gantt chart. This led us to the creation of 2 relationships linked to the same table. This article describes the problem with more details, and explains the solution that we came up with, which allowed us to only have one intermediate table to handle the two relationships.
Let's tackle this !
In the Gantt logic, a workflow can be parallelized, enabling several teams to work at the same time on the same project.
In order for us to be as close as possible to what's happening in our production center, and since we wanted to optimize those processes, we had to implement a flexible enough architecture.
The goal is to allow one Task to have several Tasks as successors while also allowing several other tasks to act as predecessors of that same task.
We wanted our model to look like this:
class Task < ActiveRecord::Base
has_many :successors, class_name: self.name
has_many :predecessors, class_name: self.name
*
*
end
Obviously, since we have two has_many/has_many relationship, intermediate tables are required as foreign keys in order to have a correct database. But these two relationship are inverse of one another, which will allow us to only have one intermediate table with two columns, one for the predecessor_id
and the other one for the successor_id
For example, the following situation :
Can be transcribed into those dependencies :
Predecessor_id | Successor_id |
---|---|
1 | 2 |
1 | 3 |
2 | 4 |
3 | 4 |
This is how we designed the TaskDependency
table. The migration is quite straightforward once we have this design in mind
class CreateTaskDependency < ActiveRecord::Migration
def change
create_table :task_dependencies do |t|
t.integer :successor_id
t.integer :predecessor_id
end
add_index :task_dependencies, [:successor_id, :predecessor_id]
add_foreign_key :task_dependencies, :tasks, column: :successor_id, name: "task_dependencies_sucessor_id_fk"
add_foreign_key :task_dependencies, :tasks, column: :predecessor_id, name: "task_dependencies_predecessor_id_fk"
end
end
The model is also simple since the TaskDependency
model has no goal except to be used as an intermediate.
class TaskDependency < ActiveRecord::Base
belongs_to :successor, class_name: 'Task'
belongs_to :predecessor, class_name: 'Task'
end
Finally, we rewrote our dependencies in order to create has_many through dependencies with our newly made foreign key
class Task < ActiveRecord::Base
has_many :task_dependency_successors, foreign_key: :successor_id, class_name: 'TaskDependency', dependent: :destroy
has_many :task_dependency_predecessors, foreign_key: :predecessor_id, class_name: 'TaskDependency', dependent: :destroy
*
*
end
This system allows us to rely as heavily as possible on ActiveRecord. The intermediate table is being automatically populated, and we never have to care about its content except for very precise operations. Most of the task-handling is done this way :
task1 = Task.create(name: 'Task1') #=> Task1
task2 = Task.create(name: 'Task2') #=> Task2
task1.successors << task2
task1.successors #=> [task2]
task2.predecessors #=> [task1]
Having this done before going into details allowed us to really focus into the sensitive stuff like inserting a task between two existing ones or creating special behaviours. That architecture is also easy to test, we can create the dependencies easily and verify the flow thanks to the predecessors
and successors
methods.