Acts As Dag
Acts As Dag, short for Acts As Directed Acyclic Graph, is a plugin which allows you to represent DAG hierarchy using your ActiveRecord models.
Say you have been using one of the many great plugins that allow for Tree hierarchy like: acts_as_tree or acts_as_nested_set, acts_as_better_nested_set, etc. Yet, you feel something is missing. Tree's are just not good enough. You want to allow each record to have multiple parent objects within a hierarchy as well as multiple children. Then you are going to need a DAG instead of a tree, and thats were this plugin becomes useful.
Version 1.1.2 tested using Rails 2.3.8 and Ruby 1.9.2-p0 and Ruby 1.8.x.
Version 2.0.x tested using Rails 3.0.0 and Ruby 1.9.2-p0, without major structural changes to code from the Rails 3 framework. Removes all deprecation warnings I have received while using AAD and Rails 3.0.0.
Version 2.5.3 is based on Rails 3.0.0 and uses all of the new ActiveModel/ActiveRecord goodness found there.
Version 3.0.0 is based on Rails 3.2.12 and tested against Ruby 1.9.3-p385. 3.0.0 fixes issues with the ActiveRecord method connected? being overwritten by acts-as-dag. Thanks to Ryan Glover (@ersatzryan) for the patch.
What's a DAG?
Aren't there plugins for this already?
Yes, but I think they aren't as fast or feature filled. Flogic has a good simple acts_as_dag plugin on github which would be similar to acts_as_tree.
DAG graph functionality
Association injection for graph nodes
Instance method injections (root?,leaf?) for graph nodes
O(1) lookups, no breath or depth first searching
O(x*y) insert, update and delete, where x & y are the number of ancestors and descendants of a node.
Version 1.x of Acts As Dag uses named_scope so your going to need Rails 2.1 or above.
Version 2.x of Acts As Dag uses validate and scope/where and ActiveModel::Validator, so you are going to need Rails 3.0 or higher.
gem install acts-as-dag
Add to your Gemfile:
Node: A source or destination of a link or edge in the graph.
Edge: A direct connection between two nodes. These can only be created by your application.
Link: A indirect connection between two nodes. Denotes the existence of a path. These can only be created by the plugin internally.
This section outlines the two methods that need to be included in your ActiveRecord models. In general this whole plugin can be thought of as one big has_many association.
This singleton class method needs to be called in your ActiveRecord model that represents the links for the DAG graph. For non-polymorphic graphs it has a required parameter:
:node_class_name => 'Class Name of the node model'
If the graph is polymorphic :node_class_name is unnecessary. A polymorphic call would be:
:polymorphic => true
:ancestor_id_column. By default, 'ancestor_id', column to use for the ancestor reference
:descendant_id_column. By default, 'descendant_id', column to use for the descendant reference
:direct_column. By default, 'direct', boolean column that represents whether the link is an edge (direct)
:count_column. By default, 'count', represents the number of ways to get from A to B.
:polymorphic. By default, false, If you want polymorphic graphs see below.
With polymorphic graphs we also have…
:ancestor_type_column. By default, 'ancestor_type', column to use for the ancestor type reference
:descendant_type_column. By default, 'descendant_type', column to use for the descendant type reference
Required Table Columns
Each of the optional column parameters needs a field in the link table. Hence for non-polymorphic graphs a migration would look like…
create_table :links do |t| t.integer :ancestor_id t.integer :descendant_id t.boolean :direct t.integer :count end
And for polymorphic graphs…
create_table :links do |t| t.integer :ancestor_id t.string :ancestor_type t.integer :descendant_id t.string :descendant_type t.boolean :direct t.integer :count end
Calling acts_as_dag_links method in the class definition injects the following associations.
belongs_to :ancestor belongs_to :descendant
Injected Named Scopes
The receives the following named scopes. The following two scopes narrows a query to only links with a certain ancestor or descendant
named_scope :with_ancestor(ancestor_instance) named_scope :with_descendant(descendant_instance)
The scopes below narrow queries to direct or indirect links
named_scope :direct named_scope :indirect
The scopes below attach the actual ancestor or descendant nodes
named_scope :ancestor_nodes, :joins => :ancestor named_scope :descendant_nodes, :joins => :descendant
Injected Class Methods
Several class methods get added to the link model to make it easier to find and create edges, and find links.
#Finds an edge of returns nil self.find_edge(ancestor,descendant) #Returns true if an edge exists self.edge?(ancestor,descendant) self.direct?(ancestor,descendant) #Finds a link or returns nil self.(ancestor,descendant) #Returns true if a link exists self.connected?(ancestor,descendant) #Creates an edge between an ancestor and descendant self.create_edge(ancestor,descendant) self.connect(ancestor,descendant) #Creates an edge using save! between an ancestor and descendant self.create_edge!(ancestor,descendant) self.connect!(ancestor,descendant) #Builds an edge between an ancestor and descendant, returning an unsaved edge self.build_edge(ancestor,descendant) #Finds and returns the edge if it exists, or calls build_edge self.find_or_build_edge(ancestor,descendant)
Injected Instance Methods
Here is a sample of some of the important instance methods that get added to the link model.
#whether the current edge can be destroyed. If the edge also has a link, ie it can be made indirectly, then it cannot be destroyed. destroyable?() #Make the edge indirect. Removes the direct edge but keeps the indirect links make_indirect() #Makes the link direct. Adds a direct edge onto a link. make_direct() #Number of unique ways to get from the ancestor to the descendant count()
This singleton class method can be optionally called from the node ActiveRecord model. If you do not call it you don't get all the nice associations within the node model, yet everything will still work fine. It takes the required parameter:
:link_class_name => 'Class Name of the link model'
:prefix. By default, ''. Use a prefix if your model calls has_dag_links for multiple link classes.
If your link class holds a polymorphic graph you also have…
:ancestor_class_names. By default , array of class names that are ancestors to this class.
:descendant_class_names. By default , array of class names that are descendants to this class.
has_dag_links provides a number of has_many and has_many_through associations.
links_as_ancestor: Has many association that finds the links with the current instance as ancestor
links_as_descendant: Has many association that finds the links with the current instance as descendant
links_as_parent: Has many association that finds direct links (edges) with the current instance as ancestor
links_as_child: Has many association that finds direct links (edges) with the current instance as descendant
Note that if a record is in links_as_parent it will be in links_as_ancestor. Also note that adding records to either produces the same result as you can only add direct links (edges). Currently there is also an error if a record exists for links_as_ancestor and you try to add it to links_as_parent. The correct way is to use the make_direct method on the link instance.
For non-polymorphic graphs you also get the following associations. These find the actual ancestors as opposed to the link instances.
For polymorphic graphs where the ancestor_class_names or descendant_class_names includes the specified class names the following associations are also built. For each ancestor_class_name:
For each descendant_class_name
Injected Instance Methods
Along with the above associations a number of instance methods are defined.
leaf? , Boolean value as to whether the current node is a leaf (no descendants)
root? , Boolean value as to whether the current node is a root (no ancestors)
With polymorphic graphs for each ancestor_class_name another method is defined.
root_for_#ancestor_class_nameancestor_class_name.tableize? , Returns true if the node has no ancestors of the represented type.
Likewise for each descendant_class_name.
leaf_for_#descendent_class_namedescendent_class_name.tableize? , Returns true if the node has no descendants of the represented type.
This section goes over some basic usage examples and presents some caveats.
Basic Non-polymorphic Graphs.
class Node < ActiveRecord::Base :link_class_name => 'Link' end class Link < ActiveRecord::Base :node_class_name => 'Node' end #Adding an edge parent_node = Node.create! child_node = Node.create! parent_node.children << child_node #Removing it = Link.(parent_node,child_node) if .destroyable? .destroy else .make_indirect .save! end
Caveats for Adding, Updating, Deleting Links
Due to the algorithm used there are some caveats for modifying links. Don't worry if you try to do something illegal because it will raise exceptions and not muck the graph up.
You can only create new edges where a link doesn't already exist. If the link exists and you want to make it direct you need to promote it using make_direct instance method. Also you can only create direct links. This makes sense because indirect links are dependent on direct links. An indirect link by itself makes no sense to the algorithm. In reverse, you can only delete links which are direct and have no indirect nature. Hence the link only exists because it is direct and not through a sequence of other links. Instead of deleting these types of links you can downgrade them using make_indirect. This will remove the direct edge A -> C and leave the transitive edge from A -> B and B -> C. So just remember before you add, check if it exists, or use one of the link class method create_edge! which will always work.
This section briefly explains how the plugin works. For a really detailed understanding look at the perpetuate function which is used to fix up the graph when its changed.
To start there is some terminology:
Node is a point on the graph
Edge is a direct connection between two nodes.
Link is a connection using one or more edges.
This implementation works by storing every possible link as a record in the database. The links originate at the ancestor and end at the descendant. Hence checking if X is a descendant of Y can be accomplished in one SQL query. Likewise finding all descendants or ancestors of Y can also be done in a single query. There are simple queries that don't use recursive stored procedures that you would need with a parent child model. Hence getting information out of the database is really fast. You can also find out the number of unique ways X is connected to Y and whether it has a direct connection. This allows the finding of all parents or children of a node with one query.
The downside to this implementation, besides the large storage requirements, is that updating the graph by adding or removing nodes or edges is not trivial. When edges are added a sort of cross product between the parent and child nodes ancestors and descendants is done that updates the counts and creates new links as necessary. Hence the complexity of an update, add, or deletion, matters heavily on how your graph is arranged and what nodes you are connecting.
Whoever did the awesome_nested_set plugin. This is my first plugin and I used that as a base. The Rails Way was also a great book that essentially taught me Rails.
Matthew Leventi, Robert Schmitt
Algorithm and plugin designed by Matthew Leventi Email:(first letter of my first name followed by my last name @gmail.com). Im open to questions, very open to bugs, and even more receptive of bug fixes. I am also currently (August 2008) looking for a job just having graduated University of Rochester.
Gemification and upgrade to Rails 3.0.0 and Ruby 1.9.2 performed by Robert Schmitt. Please feel free to contact me with bug reports and suggestions, and any patches you may have developed/applied.