Hierarchies With Rails
Previously we looked at modeling hierarchies in Postgres using a materialized path. Now, we’ll put this into practice using ActiveRecord.
First a quick recap. A materialized path, is an array containing the IDs of all the parent records. The hierarchy and path looked like this:
id | name | path
---+---------------+-----------
1 | Harold | []
2 | Arthur | [1]
3 | Alice | [1,2]
4 | Rose | [1,2,3]
5 | Bob | [1,2]
6 | Sally | [1]
7 | Mortimer | [1,6]
8 | Penny | [1,6]
The astute observer might notice that previously we encoded each record’s id into its own path. While it was useful to have the record’s id in the path when aggregating hierarchies, it is more natural not to include it when dealing with ActiveRecord. Converting between the two representations is pretty straightforward.
Depth
Since ActiveRecord 4.x has added support for Postgres arrays, finding the depth of a record is trivial. We can find the depth by looking at the path.length
:
class Employee < ActiveRecord::Base
def depth
path.length
end
...
That’s simple enough, now you can find Bob’s depth:
# Bob is ID 5
Employee.find(5).depth #=> 2
Parents
The parents of a record are equally easy to find since they are encoded in the path:
class Employee < ActiveRecord::Base
...
def parents
Employee.find(path)
end
Now you can find Bob’s parents:
# Bob is ID 5
Employee.find(5).parents #=> [Harold, Arthur, Bob]
Children
The children are slightly trickier. The materialized path has the ids of all the parents, so if we want to find a record’s children we look for all the records that contain that id in the path.
class Employee < ActiveRecord::Base
...
def children
Employee.where('path && ARRAY[?]', id)
end
It is worth noting that children
returns a proxy object, and as you will see below can be chained with other ActiveRecord operations.
Moving Records
The final piece is moving a record. We have optimized for making queries easy at the expense of making updating the hierarchy more complicated. Take a look at the full move!
method, then we will step through it.
class Employee < ActiveRecord::Base
...
def move!(new_parent)
if new_parent.path.include?(id)
throw ArgumentError "Cannot move under a child record"
end
new_path = new_parent.full_path
Employee.transaction do
children.update_all [
'path = ARRAY[:new_path] || path[:depth + 1 : array_length(path,1)]',
new_path: new_path,
depth: depth
]
self.path = new_path
self.save!
end
end
As an example we will move Alice under Bob:
id | name | path
---+---------------+-----------
1 | Harold | []
2 | Arthur | [1]
3 | Alice | [1,2]
4 | Rose | [1,2,3]
5 | Bob | [1,2]
...
The first step is to verify that the new parent is valid. new_parent.path.include?(id)
will return true if new_parent
is one of this record’s children. This prevents moving a record under one of its own children. Bob’s path [1,2]
does not contain Alice (id:3
), so this is a valid operation.
Our record’s new_path
will be the new parent’s path plus the parent’s id, which we will call the record’s full_path
. Bob’s full_path
is [1,2,5]
.
class Employee < ActiveRecord::Base
...
def full_path
path + [id]
end
Since we make two database calls, we use a transaction, Employee.transaction do ... end
. If for any reason either update fails, all the records will be rolled back, and the exception will bubble out of our move!
method.
We will update the children first, then update our record. We can use the children
method defined earlier to scope our update.
Each record’s path is split into two parts the first is the new path, the second is the part that doesn’t need to change.
| Old | New
--------------|---------|-------- Alice’s path: | [1,2] | [1,2,5] Rose’s path: | [1,2,3] | [1,2,5,3]
Rose’s path will be split up into [1,2]
and [3]
. The [1,2]
portion needs to be replaced with Alice’s new path, [1,2,5]
. The ARRAY[:new_path]
gives us the [1,2,5]
. We get the second part of Rose’s new path by slicing everything after the [1,2]
, path[:depth + 1 : array_length(path,1)]
.
Finally we set the new path on our record and save it. save!
will throw an exception if the record is invalid, this is quite useful because that will force the transaction to rollback. You should however be prepared to handle the exception one way or another when calling move!
.
Now you can wrap up materialized path hierarchies in rails. There are a lot of different ways to handle updating the hierarchy, so by all means, play around with it and look for one that fits your code base.
If you want to see the full code example, check it out on Github.