Dagable
Dagable provides directed acyclic graph (DAG) composition for ActiveRecord models using a dedicated ancestry table. It materializes all transitive paths so that traversal queries are single JOINs instead of recursive CTEs.
Features
- Pre-computed ancestry — all transitive paths are materialized in an ancestry table, so traversal queries are single JOINs instead of recursive CTEs
-
Cycle detection — raises
Dagable::Errors::CyclicAssociationbefore any invalid edge is persisted (self-referential, direct, and transitive cycles) -
ActiveRecord::Relation returns — all traversal methods (
self_and_successors,self_and_predecessors,successors,predecessors) return chainable relations - Domain-agnostic — no knowledge of your application domain; works with any ActiveRecord model
- Pure ActiveRecord — only requires ActiveRecord, not Rails
Installation
Add to your Gemfile:
gem 'dagable'Then run:
bundle installOr install directly:
gem install dagableSetup
1. Create the database tables
Dagable requires two supporting tables per model: an edges table for direct relationships and an ancestries table for all transitive paths.
class CreateCategoryDag < ActiveRecord::Migration[8.1]
include Dagable::MigrationHelpers
def change
create_table :categories do |t|
t.string :name, null: false
t.timestamps
end
create_dagable_tables(:categories)
end
endcreate_dagable_tables(:categories) creates:
| Table | Columns | Indexes |
|---|---|---|
categories_edges |
parent_id, child_id
|
Unique composite [parent_id, child_id]
|
categories_ancestries |
predecessor_id, successor_id, depth
|
Unique composite [predecessor_id, successor_id], individual on predecessor_id and successor_id
|
Both tables include foreign keys back to the source table.
2. Activate the model
class Category < ActiveRecord::Base
extend Dagable::Model
dagable
endCalling dagable will:
- Define
Category::EdgeandCategory::Ancestry(dynamic ActiveRecord classes) - Set up
has_manyassociations for edges and ancestry connections - Include traversal and edge management instance methods
- Register an
after_createcallback for self-referential ancestry rows
Usage
Adding edges
electronics = Category.create!(name: "Electronics")
phones = Category.create!(name: "Phones")
smartphones = Category.create!(name: "Smartphones")
electronics.add_child(phones)
phones.add_child(smartphones)
# Or equivalently:
smartphones.add_parent(phones)Traversal
All traversal methods return ActiveRecord::Relation, so you can chain scopes, pluck, count, etc.
electronics.self_and_successors
# => [Electronics, Phones, Smartphones]
electronics.successors
# => [Phones, Smartphones]
smartphones.self_and_predecessors
# => [Electronics, Phones, Smartphones]
smartphones.predecessors
# => [Electronics, Phones]Direct relationships
Access direct parents/children via the edge associations:
electronics.children
# => [Phones]
phones.parents
# => [Electronics]
phones.children
# => [Smartphones]Removing edges
phones.remove_child(smartphones)
# Or equivalently:
smartphones.remove_parent(phones)After removal, the ancestry table is automatically rebuilt to reflect the new graph state.
Cycle detection
Dagable prevents cycles at edge-creation time:
electronics = Category.create!(name: "Electronics")
phones = Category.create!(name: "Phones")
accessories = Category.create!(name: "Accessories")
electronics.add_child(phones)
phones.add_child(accessories)
accessories.add_child(electronics)
# => raises Dagable::Errors::CyclicAssociation
electronics.add_child(electronics)
# => raises Dagable::Errors::CyclicAssociationValid DAG structures like diamonds (multiple paths converging) are allowed:
electronics = Category.create!(name: "Electronics")
phones = Category.create!(name: "Phones")
computers = Category.create!(name: "Computers")
chargers = Category.create!(name: "Chargers")
electronics.add_child(phones)
electronics.add_child(computers)
phones.add_child(chargers)
computers.add_child(chargers) # diamond — this is fineSeeding in migrations
Use Dagable::Migrations::Helper to create records with edges in a single transaction:
Dagable::Migrations::Helper.create_dagable_item(
Category,
{ name: "Electronics" },
children: [phones, laptops],
)Architecture
How the ancestry table works
The ancestry table stores every reachable pair (predecessor, successor) with a depth:
| predecessor_id | successor_id | depth |
|---|---|---|
| Electronics | Electronics | 0 |
| Phones | Phones | 0 |
| Smartphones | Smartphones | 0 |
| Electronics | Phones | 1 |
| Phones | Smartphones | 1 |
| Electronics | Smartphones | 2 |
- Depth 0 — self-referential row (every node has one)
- Depth 1 — direct parent-child relationship
- Depth N — transitive relationship N edges apart
This allows traversal queries to be simple JOINs:
-- self_and_successors for Electronics
SELECT categories.*
FROM categories
INNER JOIN categories_ancestries ON categories_ancestries.successor_id = categories.id
WHERE categories_ancestries.predecessor_id = electronics.idKey design decisions
- Two tables per model: edges store direct relationships; ancestries store all transitive paths. This separation makes edge removal clean (delete edge, rebuild ancestries from remaining edges).
-
Self-referential ancestry rows: every node has a depth-0 row pointing to itself. This simplifies JOIN-based traversal by ensuring
self_and_successorsnaturally includes the node itself. -
link_ancestrieson Edge: when an edge is created, the Edge computes the cross product of the parent's predecessors with the child's successors and bulk-inserts ancestry rows for each pair. This is idempotent (existing rows are skipped viaINSERT ... ON CONFLICT DO NOTHING). - Rebuild on removal: when an edge is removed or a node is destroyed, all non-self ancestry rows are deleted and rebuilt from the remaining edges. This is the safest approach for correctness.
Development
bundle install
bundle exec rspecTests run against an in-memory SQLite database — no external database required.
License
This gem is available as open source under the terms of the MIT License.