Project

dagable

0.0
No release in over 3 years
Provides reusable directed acyclic graph (DAG) composition via a dedicated ancestry table for any ActiveRecord model.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.3
~> 3.13
~> 1.75
~> 2.9

Runtime

~> 8.1, >= 8.1.3
 Project Readme

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::CyclicAssociation before 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 install

Or install directly:

gem install dagable

Setup

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
end

create_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
end

Calling dagable will:

  1. Define Category::Edge and Category::Ancestry (dynamic ActiveRecord classes)
  2. Set up has_many associations for edges and ancestry connections
  3. Include traversal and edge management instance methods
  4. Register an after_create callback 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::CyclicAssociation

Valid 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 fine

Seeding 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.id

Key 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_successors naturally includes the node itself.
  • link_ancestries on 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 via INSERT ... 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 rspec

Tests 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.