Hierarchable
A simple way to define cross model hierarchical (parent, child, sibling) relationships between ActiveRecord models.
The aim of this library is to efficiently create and store the ancestors of an object so that it is easy to generate things like breadcrumbs that require information about an object's ancestors that may span multiple models (e.g. Project
and Task
). It is designed in such a way that each object contains the ancestry information and that no joins need to be made to a separate table to get this ancestry information.
Installation
Add this line to your application's Gemfile:
gem 'hierarchable'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hierarchable
Once the gem is installed, you will need to make sure that your models have the correct columns.
hierarchy_root
: The root node in the hierarchy hierarchy (polymorphic)hierarchy_parent
: The parent of the current object (polymorphic)hierarchy_ancestors_path
: The string representation of all ancestors of the current object (string).
Assuming that you are using UUIDs for your IDs, this can be done by adding the following to the model(s) for which you wish to have hierarchy information:
t.references :hierarchy_root,
polymorphic: true,
null: true,
type: :uuid,
index: true
t.references :hierarchy_parent,
polymorphic: true,
null: true,
type: :uuid,
index: true
t.string :hierarchy_ancestors_path, index: true
If you aren't using UUIDs, then simply omit the type: :uuid
from the two references
definitions.
Note, the hierarchy_ancestors_path
column does contain all of the information that is in the hierarchy_root
and hierarchy_parent
columns, but those two columns are created for more efficient querying as the direct parent and the root are the most frequent parts of the hierarchy that are needed.
Usage
Getting Started
We will describe the usage using a simplistic Project and Task analogy where we assume that a Project can have many tasks. Given a class Project
we can set it up as follows
class Project
include Hierarchable
hierarchable
# If desired, one could explicitly set the parent source to `nil` for clarity,
# but this is the same as omitting it.
# hierarchable parent_source: nil
end
This will set up the Project
as the root of the hierarchy. When a Project
model is saved, it will not have any values for the hierarchy_root, hierarchy_parent, or hierarchy_ancestors_path (i.e. they are nil
). This is because for the root item we are not guaranteed to have an ID for the object until after it is saved. Thus, there is no consistent way for us to set these values across different use cases. This doesn't affect any of the usage of the library, it's just something to keep in mind.
project = Project.create!
# These will be true.
project.hierarchy_root == nil
project.hierarchy_parent == nil
project.hierarchy_ancestors_path == ''
Now that we have a project configured, we can add tasks that have projects as a parent.
class Task
include Hierarchable
hierarchable parent_source: :project
belongs_to :project
end
This will configure the hierarchy to look at the project association and use that to compute the parent. So now when we instantiate a task, the parent and root of that task will be the project.
project = Project.create!
task = Task.create!(project: project)
# These will be true (assuming that the xxxxxx and yyyyyy are the IDs for the
# project and task respectively)
task.hierarchy_root == project
task.hierarchy_parent == project
task.hierarchy_ancestors_path == 'Project|xxxxxx/Task|yyyyyy'
Now, let's assume that our tasks can also have other Tasks as subtasks. Once we do that, we need to ensure that the parent of a subtask is the task and not the project. For this we, can do something like the following:
class Task
include Hierarchable
hierarchable parent_source: ->(object) {
obj.parent_task.present? ? :parent_task : :project
}
belongs_to :project
belongs_to :parent_task,
class_name: 'Task',
optional: true
has_many :sub_tasks,
class_name: 'Task',
foreign_key: :parent_task
inverse_of: :parent_task,
dependent: :destroy
end
What we have done here is configured the source attribute for the hierarchy computation to be :parent_task
if the task has a parent_task
set (i.e. it's a subtask), or use :project
if one is not set (i.e. it's a top level task).
project = Project.create!
task = Task.create!(project: project)
sub_task = Task.create!(project: project, parent_task: task)
# These will be true
sub_task.hierarchy_root == project
sub_task.hierarchy_parent == task
sub_task.hierarchy_ancestors_path == 'Project|xxxxxx/Task|yyyyyy/Task|zzzzzz'
Core functionality
The core methods that are of interest are the following:
project.hierarchy_ancestors
project.hierarchy_parent
project.hierarchy_siblings
project.hierarchy_children
project.hierarchy_descendants
The major distinction for what is returned is whether you are querying "up the hierarchy" or "down the hierarchy". As there is only 1 path up the hierchy to get to the root, the return values of hierarchy_ancestors
is a list and hierarchy_parent
is a single object. However, traversing down the list is a little more tricky as there are various models and potential paths to get all the way do to the leaves. As such, for all methods at the same level or going down the tree (hierarchy_siblings
, hierarchy_children
, and hierarchy_descendants
), the return value is a hash that has the model class as the key, and either a ActiveRecord::Relation
or a list as the value. For example, for a Project model that has tasks and milestones as descendants, the return value might be something like
{
'Task': [all descendant tasks starting at the project]
'Milestone': [all descendant milestones starting at the project]
}
Given the architecture of this library, this is the most efficient way to return all objects with as few queries as possible.
Limiting the objects returned
All of the methods (except hierarchy_parent
) take a models
paramter that can be used to limit the results returned. The potential values are
:all
(default): Return all objects regardless of type:this
: Return only objects of the SAME time as the current object- An array of models of interest: Return only the objects of the type(s) that are specified (e.g. [
Project
] or [Project
,Task
]). The models can be passed either as class objects or a string that can be turned into a class object viasafe_constantize
.
There are times when we only need to get the siblings/children/descendants of one type and having a hash returned is a little cumbersome. To deal with this case, you can pass compact: true
as a parameter and it will return just single result not as a hash. For example:
# Returns as a hash of the form `{Task: [..all descendants..]}`
project.hierarch_descendants(models: ['Task'])
# Returns just the result: `[..all descendants..]`
project.hierarch_descendants(models: ['Task'], compact: true)
Working with siblings and descendants of an object
Let's continue with our Project
and Task
example from above and assume we have the following models:
class Project
include Hierarchable
hierarchable
end
class Task
include Hierarchable
hierarchable parent_source: :project
belongs_to :project
end
class Milestone
include Hierarchable
hierarchable parent_source: :project
belongs_to :project
end
Based on this setup, we can get all siblings and descendants of either a Project
or Task
as follows:
project = Project.create!
task = Task.create!(project: project)
milestone = Milestone.create!(project: project)
# Query for all Project objects that are siblings of this project.
# Since the project is the root of the hierarchy, this will return no siblings
project.hierarchy_siblings
# Query for all objects (regardless of type) that are descendats of this project
# In our example, this will return all Tasks and Milestones
project.hierarchy_descendants
# Query for all Project objects that are descendats of this project
# In our example, this will return no results
project.hierarchy_descendants(models: :this)
# Query for all Task objects that are siblings of this task.
# This will return all tasks and milestones that are part of the project
task.hierarchy_siblings
In order to figure out the potential descendants of an object we need to inspect the object and query all relations to to see if any of those have this object as an ancestor. In many cases these relations can be inferred correctly by getting all of the has_many
relationships that a model has defined. To be safe and not return potential duplicate associations, the only associations that are automatically detected are the ones that are the pluralized form of the model name.
class Project
has_many :tasks
has_many :completed_tasks, -> { completed }, class_name: 'Task'
has_many :timestamps, class_name: 'MetricLibrary::Timestamp`
end
In the Project
model defined above, only the :tasks
association will be used for finding descendants.
However there are times when we need to manually add a child relation to be inspected. This can be done in one of two ways. The most common case is if we want to specify additional associations. This will take all of the associations that can be auto-detected and also add in the one provided.
class SomeObject
include Hierarchable
hierarched parent_source: :parent,
additional_descendant_associations: [:some_association]
end
There may also be a case when we want exact control over what associations that should be used. In that case, we can specify it like this:
class SomeObject
include Hierarchable
hierarched parent_source: :parent,
descendant_associations: [:some_association]
end
Configuring the separators
By default the separators to use for the path and records are /
and |
respectively. This means that a hierarchy path will look something like
<record1_type>|<record1_id>/<record2_type>|<record2_id>
In the event you need to modify the separators used to build the path, you can pass in your desired separators:
class SomeObject
include Hierarchable
hierarchable parent_source: :parent_object,
path_separator: '@@',
record_separator: '++'
belongs_to :parent_object
end
Assuming that you set the separators like that for all models, then your path will look something like
<record1_type>++<record1_id>@@<record2_type>++<record2_id>
CAUTION: When setting custom path and/or record separators, do not use any characters that are likely to be in class/module names such as -, _, :, etc. Otherwise it will not be possible to determine the objects in the path.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/prschmid/hierarchable.
License
The gem is available as open source under the terms of the MIT License.