Fetching ancestors/sibling records per locale in ActiveRecord
Context
There are times when we need to work with parent
records
within our database.
A practical example we will use today is Pages
table with
a parent_id
column, referencing another Page
record.
This will be used in building page breadcrumbs(parents) and fetching recommended pages(siblings).
pizza_recipe_page = Page.last
recipes_page = pizza_recipe_page.parent
This builds up a tree like structure, and we would most
probably need a way to fetch the page
's parent pages or related pages
when working in our application.
pages_for_breadcrumbs = page.self_and_parent_nodes
recommended_pages = page.siblings
Problem
For straightforward cases, we may opt out to use a gem such as
ancestry and be done
with the task. This will work, assuming you are using one column
such as parent_id
to set a parent for the record.
class Page
# assumes your parent record
# reference is based only on a single column
has_ancestry
end
However, you would run into a slight inconvenience if you are using multiple columns to set parents, such as for different languages(parent_id_en, parent_id_es, parent_id_it).
Pages | Field |
---|---|
parent_id_es | Parent page for spanish locale |
parent_id_en | Parent page for english locale |
parent_id_it | Parent page for italian locale |
Now, imagine querying for related pages per locale, and generating breadcrumbs… Not so straightforward!
Solution
Thus, we have to build functionality on our own to enable fetching ancestors/siblings per locale.
PostgreSQL recursive queries for the rescue!
This assumes you are using ActiveRecord. However, you may use the same concept regardless of the ORM.
class Page
# Starting with the direct parent
# iterate through the chain, querying
# parent of the found record as we go higher
# terminating when nothing is found.
# Maintains the correct order of results.
# So, ids will be self_id, parent_node_id,
# greatparent_node_id, greatgreatparent_node_id...
# @returns ActiveRecord::Relation
def self_and_parent_nodes(locale:)
query = <<~SQL
WITH RECURSIVE parent_nodes AS (
SELECT id, parent_id_#{locale}
FROM #{self.class.table_name}
WHERE id = #{id}
UNION
SELECT c.id, c.parent_id_#{locale}
FROM #{self.class.table_name} c
JOIN parent_nodes p ON p.parent_id_#{locale} = c.id
) SELECT id FROM parent_nodes;
SQL
self_and_parent_node_ids =
::ApplicationRecord.connection
.execute(query)
.map { |record| record.fetch('id') }
# Fetch AR objects from returned collection of ids
self.class
.where(id: self_and_parent_node_ids)
.ordered_by_ids(self_and_parent_node_ids)
end
# Find records that share parent with self
#
# @returns ActiveRecord::Relation
def siblings(locale:)
self.class
.where("parent_id_#{locale} = ? AND #{self.class.table_name}.id != ?",
public_send("parent_id_#{locale}"), id)
end
# Maintain order of results
#
# @returns ActiveRecord::Relation
def ordered_by_ids(ids)
order_results_query =
ids.map.with_index { |id, position| "WHEN #{id} THEN #{position}" }.join(' ')
order_sql =
Arel.sql(
"CASE #{self.table_name}.id #{order_results_query} ELSE #{ids.size} END, #{self.table_name}.id"
)
order(order_sql)
end
end
Now you are able to fetch ancestor and sibling nodes per locale. Go ahead and create/test some records.
page = Page.create!(
name: 'Test Page',
parent_id_en: id_of_some_english_page,
parent_id_es: id_of_some_spanish_page
)
pages_for_breadcrumbs =
page.self_and_parent_nodes(locale: 'en')
recommended_pages_for_spain =
page.siblings(locale: 'es')
That’s it for today! If you see an issue or have any question please give me a shout.