Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Version 1.1.0 done!

  - Depth caching (and cache rebuilding)
  - Depth method for nodes
  - Named scopes for selecting by depth
  - Relative depth options for tree navigation methods:
    - ancestors
    - path
    - descendants
    - descendant_ids
    - subtree
    - subtree_ids
  - Updated README
  - Easy migration from existing plugins/gems
  - acts_as_tree checks unknown options
  - acts_as_tree checks that options are hash
  - Added a bang (!) to the integrity functions
    - Since these functions should only be used from ./script/console and not from your appliction, this change is not considered as breaking backwards compatibility and the major version wasn't bumped.
  - Updated install script to point to documentation
  - Removed rails specific init
  - Removed uninstall script
  • Loading branch information...
commit df705c35336aba91fab7d03fb1ddf90fa1cffd4a 1 parent 488e752
@stefankroes authored
View
195 README.rdoc
@@ -1,6 +1,6 @@
= Ancestry
-Ancestry allows the records of a ActiveRecord model to be organised in a tree structure, using a single, intuitively formatted database column. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single sql query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.
+Ancestry allows the records of a ActiveRecord model to be organised as a tree structure, using a single, intuitively formatted database column, using a variation on the materialised path pattern. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single sql query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.
= Installation
@@ -17,7 +17,7 @@ To apply Ancestry to any ActiveRecord model, follow these simple steps:
2. Add ancestry column to your table
- Create migration: ./script/generate migration add_ancestry_to_[table] ancestry:string
- - Add index to migration: add_index [table], :ancestry / remove_index [table], :ancestry
+ - Add index to migration: add_index [table], :ancestry (UP) / remove_index [table], :ancestry (DOWN)
- Migrate your database: rake db:migrate
3. Add ancestry to your model
@@ -39,26 +39,41 @@ You can also create children through the children relation on a node:
To navigate an Ancestry model, use the following methods on any instance / record:
- parent Returns the parent of the record
- root Returns the root of the tree the record is in
- root_id Returns the id of the root of the tree the record is in
- is_root? Returns true if the record is a root node, false otherwise
- ancestor_ids Returns a list of ancestor ids, starting with the root id and ending with the parent id
- ancestors Scopes the model on ancestors of the record
- path_ids Returns a list the path ids, starting with the root is and ending with the node's own id
- path Scopes model on path records of the record
- children Scopes the model on children of the record
- child_ids Returns a list of child ids
- has_children? Returns true if the record has any children, false otherwise
- is_childless? Returns true is the record has no childen, false otherwise
- siblings Scopes the model on siblings of the record, the record itself is included
- sibling_ids Returns a list of sibling ids
- has_siblings? Returns true if the record's parent has more than one child
- is_only_child? Returns true if the record is the only child of its parent
- descendants Scopes the model on direct and indirect children of the record
- descendant_ids Returns a list of a descendant ids
- subtree Scopes the model on descendants and itself
- subtree_ids Returns a list of all ids in the record's subtree
+ parent Returns the parent of the record, nil for a root node
+ parent_id Returns the id of the parent of the record, nil for a root node
+ root Returns the root of the tree the record is in, self for a root node
+ root_id Returns the id of the root of the tree the record is in
+ is_root? Returns true if the record is a root node, false otherwise
+ ancestor_ids Returns a list of ancestor ids, starting with the root id and ending with the parent id
+ ancestors Scopes the model on ancestors of the record
+ path_ids Returns a list the path ids, starting with the root is and ending with the node's own id
+ path Scopes model on path records of the record
+ children Scopes the model on children of the record
+ child_ids Returns a list of child ids
+ has_children? Returns true if the record has any children, false otherwise
+ is_childless? Returns true is the record has no childen, false otherwise
+ siblings Scopes the model on siblings of the record, the record itself is included
+ sibling_ids Returns a list of sibling ids
+ has_siblings? Returns true if the record's parent has more than one child
+ is_only_child? Returns true if the record is the only child of its parent
+ descendants Scopes the model on direct and indirect children of the record
+ descendant_ids Returns a list of a descendant ids
+ subtree Scopes the model on descendants and itself
+ subtree_ids Returns a list of all ids in the record's subtree
+ depth Return the depth of the node, root nodes are at depth 0
+
+= acts_as_tree Options
+
+The acts_as_tree methods supports two options:
+
+ :ancestry_column Pass in a symbol to store ancestry in a different column
+ :orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed:
+ :destroy All children are destroyed as well (default)
+ :rootify The children of the destroyed node become root nodes
+ :restrict An AncestryException is raised if any children exist
+ :cache_depth Cache the depth of each node in the 'ancestry_depth' column (default: false)
+ If you turn depth_caching on for an existing model, use: TreeNode.rebuild_depth_cache!
+ :depth_cache_column Pass in a symbol to store depth cache in a different column
= (Named) Scopes
@@ -70,11 +85,11 @@ Where possible, the navigation methods return scopes instead of records, this me
For convenience, a couple of named scopes are included at the class level:
- roots Only root nodes
- ancestors_of(node) Only ancestors of node, node can be either a record or an id
- children_of(node) Only children of node, node can be either a record or an id
- descendants_of(node) Only descendants of node, node can be either a record or an id
- siblings_of(node) Only siblings of node, node can be either a record or an id
+ roots Root nodes
+ ancestors_of(node) Ancestors of node, node can be either a record or an id
+ children_of(node) Children of node, node can be either a record or an id
+ descendants_of(node) Descendants of node, node can be either a record or an id
+ siblings_of(node) Siblings of node, node can be either a record or an id
Thanks to some convenient rails magic, it is even possible to create nodes through the children and siblings scopes:
@@ -83,15 +98,31 @@ Thanks to some convenient rails magic, it is even possible to create nodes throu
TestNode.children_of(node_id).new
TestNode.siblings_of(node_id).create
-= acts_as_tree Options
+= Selecting nodes by depth
-The acts_as_tree methods supports two options:
+When depth caching is enabled (see acts_as_tree options), five more named scopes can be used to select nodes on their depth:
- ancestry_column Pass in a symbol to instruct Ancestry to use a different column name to store record ancestry
- orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed:
- :destroy All children are destroyed as well (default)
- :rootify The children of the destroyed node become root nodes
- :restrict An AncestryException is raised if any children exist
+ before_depth(depth) Return nodes that are less deep than depth (node.depth < depth)
+ to_depth(depth) Return nodes up to a certain depth (node.depth <= depth)
+ at_depth(depth) Return nodes that are at depth (node.depth == depth)
+ from_depth(depth) Return nodes starting from a certain depth (node.depth >= depth)
+ after_depth(depth) Return nodes that are deeper than depth (node.depth > depth)
+
+The depth scopes are also available through calls to descendants, descendant_ids, subtree, subtree_ids, path and ancestors. In this case, depth values are interpreted relatively. Some examples:
+
+ node.subtree(:to_depth => 2) Subtree of node, to a depth of node.depth + 2 (self, children and grandchildren)
+ node.subtree.to_depth(5) Subtree of node to an absolute depth of 5
+ node.descendants(:at_depth => 2) Descendant of node, at depth node.depth + 2 (grandchildren)
+ node.descendants.at_depth(10) Descendants of node at an absolute depth of 10
+ node.ancestors.to_depth(3) The oldest 4 ancestors of node (its root and 3 more)
+ node.path(:from_depth => -2) The node's grandparent, parent and the node itself
+
+ node.ancestors(:from_depth => -6, :to_depth => -4)
+ node.path.from_depth(3).to_depth(4)
+ node.descendants(:from_depth => 2, :to_depth => 4)
+ node.subtree.from_depth(10).to_depth(12)
+
+Please note that depth constraints cannot be passed to ancestor_ids and path_ids. The reason for this is that both these relations can be fetched directly from the ancestry column without performing a database query. It would require an entirely different method of applying the depth constraints which isn't worth the effort of implementing. You can use ancestors(depth_options).map(&:id) or ancestor_ids.slice(min_depth..max_depth) instead.
= Arrangement
@@ -109,9 +140,44 @@ The arrange method also works on a scoped class, for example:
TreeNode.find_by_name('Crunchy').subtree.arrange
+= Migrating from plugin that uses parent_id column
+
+Most current tree plugins use a parent_id column (acts_as_tree, awesome_nested_set, better_nested_set, acts_as_nested_set). With ancestry its easy to migrate from any of these plugins, to do so, follow these steps:
+
+1. Add ancestry column to your table
+ - Create migration: ./script/generate migration add_ancestry_to_[table] ancestry:string
+ - Add index to migration: add_index [table], :ancestry (UP) / remove_index [table], :ancestry (DOWN)
+ - Migrate your database: rake db:migrate
+
+2. Remove old tree plugin or gem and add in Ancestry
+ - Remove plugin: rm -Rf vendor/plugins/[old plugin]
+ - Remove gem config line from environment.rb: config.gem [old gem]
+ - Add Ancestry to environment.rb: config.gem :ancestry
+ - See 'Installation' for more info on installing and configuring gems
+
+3. Change your model
+ - Remove any macros required by old plugin/gem from app/models/[model].rb
+ - Add to app/models/[model].rb: acts_as_tree
+
+4. Migrate database
+ - In './script.console': [model].build_ancestry_from_parent_ids!
+ - Make sure it worked ok: [model].check_ancestry_integrity!
+
+5. Change your code
+ - Most tree calls will probably work fine with ancestry
+ - Others must be changed or proxied
+ - Check if all your data is intact and all tests pass
+
+6. Drop parent_id column:
+ - Create migration: ./script/generate migration remove_parent_id_from_[table]
+ - Add to migration: remove_column [table], :parent_id (UP) / add_column [table], :parent_id, :integer (DOWN)
+ - Migrate your database: rake db:migrate
+
= Integrity Checking and Restoration
-I don't see any way Ancestry tree integrity could get compromised without explicitly setting cyclic parents or invalid ancestry and circumventing validation with update_attribute, if you do, please let me know. I did include methods for detecting integrity problems and restoring integrity just to be sure. To check integrity use: [Model].check_ancestry_integrity. An AncestryIntegrityException will be raised if there are any problems. To restore integrity use: [Model].restore_ancestry_integrity.
+I don't see any way Ancestry tree integrity could get compromised without explicitly setting cyclic parents or invalid ancestry and circumventing validation with update_attribute, if you do, please let me know.
+
+Ancestry includes some methods for detecting integrity problems and restoring integrity just to be sure. To check integrity use: [Model].check_ancestry_integrity!. An AncestryIntegrityException will be raised if there are any problems. To restore integrity use: [Model].restore_ancestry_integrity!.
For example, from IRB:
@@ -123,29 +189,68 @@ For example, from IRB:
$ true
>> TreeNode.all
$ [#<TreeNode id: 1, name: "Stinky", ancestry: "1/2">, #<TreeNode id: 2, name: "Squeeky", ancestry: "1/2/1">]
- >> TreeNode.check_ancestry_integrity
+ >> TreeNode.check_ancestry_integrity!
!! Ancestry::AncestryIntegrityException: Conflicting parent id in node 1: 2 for node 1, expecting nil
- >> TreeNode.restore_ancestry_integrity
+ >> TreeNode.restore_ancestry_integrity!
$ [#<TreeNode id: 1, name: "Stinky", ancestry: 2>, #<TreeNode id: 2, name: "Squeeky", ancestry: nil>]
-= Testing
+Additionally, if you think something is wrong with your depth cache:
+
+ >> TreeNode.rebuild_depth_cache!
-The Ancestry gem comes with a unit test suite consisting of about 1500 assertions in 20 tests. It takes about 4 seconds to run on sqlite. To run it yourself, install Ancestry as a plugin, go to the ancestry folder and type 'rake'. The test suite is located in 'test/acts_as_tree_test.rb'.
+= Tests
+
+The Ancestry gem comes with a unit test suite consisting of about 1800 assertions in about 30 tests. It takes about 10 seconds to run on sqlite. To run it yourself, install Ancestry as a plugin into a Rails application, go to the ancestry folder and type 'rake'. The test suite is located in 'test/acts_as_tree_test.rb'.
= Internals
As can be seen in the previous section, Ancestry stores a path from the root to the parent for every node. This is a variation on the materialised path database pattern. It allows Ancestry to fetch any relation (siblings, descendants, etc.) in a single sql query without the complicated algorithms and incomprehensibility associated with left and right values. Additionally, any inserts, deletes and updates only affect nodes within the affected node's own subtree.
-In the example above, the ancestry column is created as a string. This puts a limitation on the depth of the tree of about 40 or 50 levels, which I think may be enough for most users. To increase the maximum depth of the tree, increase the size of the string that is being used or change it to a text to remove the limitation entirely. Changing it to a text will however decrease performance because a index cannot be put on the column in that case.
+In the example above, the ancestry column is created as a string. This puts a limitation on the depth of the tree of about 40 or 50 levels, which I think may be enough for most users. To increase the maximum depth of the tree, increase the size of the string that is being used or change it to a text to remove the limitation entirely. Changing it to a text will however decrease performance because an index cannot be put on the column in that case.
+
+The materialised path pattern requires Ancestry to use a 'like' condition in order to fetch descendants. This should not be particularly slow however since the the condition never starts with a wildcard which allows the DBMS to use the column index. If you have any data on performance with a large number of records, please drop me line.
+
+= Version history
+
+The latest and recommended version of ancestry is 1.1.0. The three numbers of each version numbers are respectively the major, minor and patch versions. We started with major version 1 because it looks so much better and ancestry was already quite mature and complete when it was published. The major version is only bumped when backwards compatibility is broken. The minor version is bumped when new features are added. The patch version is bumped when bugs are fixed.
+
+- Version 1.1.0 (2009-10-22)
+ - Depth caching (and cache rebuilding)
+ - Depth method for nodes
+ - Named scopes for selecting by depth
+ - Relative depth options for tree navigation methods:
+ - ancestors
+ - path
+ - descendants
+ - descendant_ids
+ - subtree
+ - subtree_ids
+ - Updated README
+ - Easy migration from existing plugins/gems
+ - acts_as_tree checks unknown options
+ - acts_as_tree checks that options are hash
+ - Added a bang (!) to the integrity functions
+ - Since these functions should only be used from ./script/console and not from your appliction, this change is not considered as breaking backwards compatibility and the major version wasn't bumped.
+ - Updated install script to point to documentation
+ - Removed rails specific init
+ - Removed uninstall script
+- Version 1.0.0 (2009-10-16)
+ - Initial version
+ - Tree building
+ - Tree navigation
+ - Integrity checking / restoration
+ - Arrangement
+ - Orphan strategies
+ - Subtree movement
+ - Named scopes
+ - Validations
= Future Work
-I will try to keep Ancestry up to date with changing versions of Rails and Ruby and also with any bug reports I might receive. I will implement new features on request as I see fit. Something that definitely needs to be added in the future is constraints on depth, something like: tree_node.subtree.to_depth(4)
-
-= Feedback
-
-Question? Bug report? Faulty/incomplete documentation? Feature request? Please contact me at s.a.kroes[at]gmail.com
+I will try to keep Ancestry up to date with changing versions of Rails and Ruby and also with any bug reports I might receive. I will implement new features on request as I see fit. One thing I definitely want to do soon is some proper performance testing.
+= Contact and Copyright
+Question? Bug report? Faulty/incomplete documentation? Feature request? Please post an issue on 'http://github.com/stefankroes/ancestry/issues'. Please also contact me at s.a.kroes[at]gmail.com if it's urgent.
Copyright (c) 2009 Stefan Kroes, released under the MIT license
View
4 Rakefile
@@ -14,9 +14,9 @@ end
desc 'Generate documentation for the ancestry plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
- rdoc.rdoc_dir = 'rdoc'
+ rdoc.rdoc_dir = 'doc'
rdoc.title = 'Ancestry'
rdoc.options << '--line-numbers' << '--inline-source'
- rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
View
6 ancestry.gemspec
@@ -5,14 +5,14 @@ Gem::Specification.new do |s|
s.description = 'Organise ActiveRecord model into a tree structure'
s.summary = 'Ancestry allows the records of a ActiveRecord model to be organised in a tree structure, using a single, intuitively formatted database column. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single sql query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.'
- s.version = '1.0.0'
- s.date = '2009-10-16'
+ s.version = '1.1.0'
+ s.date = '2009-10-22'
s.author = 'Stefan Kroes'
s.email = 's.a.kroes@gmail.com'
s.homepage = 'http://github.com/stefankroes/ancestry'
- s.files = FileList['ancestry.gemspec', '*.rb', 'lib/**/*.rb', 'rails/*', 'test/*', 'Rakefile', 'MIT-LICENSE', 'README.rdoc']
+ s.files = FileList['ancestry.gemspec', '*.rb', 'lib/**/*.rb', 'test/*', 'Rakefile', 'MIT-LICENSE', 'README.rdoc']
s.add_dependency 'activerecord', '>= 2.1.0'
end
View
1  install.rb
@@ -1 +1,2 @@
# Install hook code here
+puts "Thank you for install Ancestry. You can visit http://github.com/stefankroes/ancestry to read the documentation."
View
144 lib/ancestry/acts_as_tree.rb
@@ -11,6 +11,14 @@ def self.included base
module ClassMethods
def acts_as_tree options = {}
+ # Check options
+ raise AncestryException.new("Options for acts_as_tree must be in a hash.") unless options.is_a? Hash
+ options.each do |key, value|
+ unless [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column].include? key
+ raise AncestryException.new("Unknown options for acts_as_tree: #{key.inspect} => #{value.inspect}.")
+ end
+ end
+
# Include instance methods
send :include, InstanceMethods
@@ -28,15 +36,46 @@ def acts_as_tree options = {}
# Validate format of ancestry column value
validates_format_of ancestry_column, :with => /^[0-9]+(\/[0-9]+)*$/, :allow_nil => true
+ # Create ancestry column accessor and set to option or default
+ if options[:cache_depth]
+ self.cattr_accessor :depth_cache_column
+ self.depth_cache_column = options[:depth_cache_column] || :ancestry_depth
+ # Cache depth in depth cache column before save
+ before_save :cache_depth
+ # Named scopes for depth
+ end
+
+ # Create named scopes for depth
+ named_scope :before_depth, lambda { |depth|
+ raise AncestryException.new("Named scope 'before_depth' is only available when depth caching is enabled.") unless options[:cache_depth]
+ {:conditions => ["#{depth_cache_column} < ?", depth]}
+ }
+ named_scope :to_depth, lambda { |depth|
+ raise AncestryException.new("Named scope 'to_depth' is only available when depth caching is enabled.") unless options[:cache_depth]
+ {:conditions => ["#{depth_cache_column} <= ?", depth]}
+ }
+ named_scope :at_depth, lambda { |depth|
+ raise AncestryException.new("Named scope 'at_depth' is only available when depth caching is enabled.") unless options[:cache_depth]
+ {:conditions => ["#{depth_cache_column} = ?", depth]}
+ }
+ named_scope :from_depth, lambda { |depth|
+ raise AncestryException.new("Named scope 'from_depth' is only available when depth caching is enabled.") unless options[:cache_depth]
+ {:conditions => ["#{depth_cache_column} >= ?", depth]}
+ }
+ named_scope :after_depth, lambda { |depth|
+ raise AncestryException.new("Named scope 'after_depth' is only available when depth caching is enabled.") unless options[:cache_depth]
+ {:conditions => ["#{depth_cache_column} > ?", depth]}
+ }
+
# Validate that the ancestor ids don't include own id
validate :ancestry_exclude_self
# Named scopes
named_scope :roots, :conditions => {ancestry_column => nil}
- named_scope :ancestors_of, lambda{ |object| {:conditions => to_node(object).ancestor_conditions} }
- named_scope :children_of, lambda{ |object| {:conditions => to_node(object).child_conditions} }
- named_scope :descendants_of, lambda{ |object| {:conditions => to_node(object).descendant_conditions} }
- named_scope :siblings_of, lambda{ |object| {:conditions => to_node(object).sibling_conditions} }
+ named_scope :ancestors_of, lambda { |object| {:conditions => to_node(object).ancestor_conditions} }
+ named_scope :children_of, lambda { |object| {:conditions => to_node(object).child_conditions} }
+ named_scope :descendants_of, lambda { |object| {:conditions => to_node(object).descendant_conditions} }
+ named_scope :siblings_of, lambda { |object| {:conditions => to_node(object).sibling_conditions} }
# Update descendants with new ancestry before save
before_save :update_descendants_with_new_ancestry
@@ -49,10 +88,22 @@ def acts_as_tree options = {}
module DynamicClassMethods
# Fetch tree node if necessary
def to_node object
- object.is_a?(self) ? object : find(object)
+ if object.is_a?(self) then object else find(object) end
end
- # Orhpan strategy writer
+ # Scope on relative depth options
+ def scope_depth depth_options, depth
+ depth_options.inject(self) do |scope, option|
+ scope_name, relative_depth = option
+ if [:before_depth, :to_depth, :at_depth, :from_depth, :after_depth].include? scope_name
+ scope.send scope_name, depth + relative_depth
+ else
+ raise Ancestry::AncestryException.new("Unknown depth option: #{scope_name}.")
+ end
+ end
+ end
+
+ # Orphan strategy writer
def orphan_strategy= orphan_strategy
# Check value of orphan strategy, only rootify, restrict or destroy is allowed
if [:rootify, :restrict, :destroy].include? orphan_strategy
@@ -77,7 +128,7 @@ def arrange
end
# Integrity checking
- def check_ancestry_integrity
+ def check_ancestry_integrity!
parents = {}
# For each node ...
all.each do |node|
@@ -102,7 +153,7 @@ def check_ancestry_integrity
end
# Integrity restoration
- def restore_ancestry_integrity
+ def restore_ancestry_integrity!
parents = {}
# For each node ...
all.each do |node|
@@ -125,11 +176,27 @@ def restore_ancestry_integrity
# ... rebuild ancestry from parents array
ancestry, parent = nil, parents[node.id]
until parent.nil?
- ancestry, parent = ancestry.nil? ? parent : "#{parent}/#{ancestry}", parents[parent]
+ ancestry, parent = if ancestry.nil? then parent else "#{parent}/#{ancestry}" end, parents[parent]
end
node.update_attributes node.ancestry_column => ancestry
end
end
+
+ # Build ancestry from parent id's for migration purposes
+ def build_ancestry_from_parent_ids! parent_id = nil, ancestry = nil
+ all(:conditions => {:parent_id => parent_id}).each do |node|
+ node.update_attribute ancestry_column, ancestry
+ build_ancestry_from_parent_ids! node.id, if ancestry.nil? then "#{node.id}" else "#{ancestry}/#{node.id}" end
+ end
+ end
+
+ # Build ancestry from parent id's for migration purposes
+ def rebuild_depth_cache!
+ raise Ancestry::AncestryException.new("Cannot rebuild depth cache for model without depth caching.") unless respond_to? :depth_cache_column
+ all.each do |node|
+ node.update_attribute depth_cache_column, node.depth
+ end
+ end
end
module InstanceMethods
@@ -149,7 +216,7 @@ def update_descendants_with_new_ancestry
self.class.ancestry_column =>
descendant.read_attribute(descendant.class.ancestry_column).gsub(
/^#{self.child_ancestry}/,
- (read_attribute(self.class.ancestry_column).blank? ? id.to_s : "#{read_attribute self.class.ancestry_column }/#{id}")
+ if read_attribute(self.class.ancestry_column).blank? then id.to_s else "#{read_attribute self.class.ancestry_column }/#{id}" end
)
)
end
@@ -163,7 +230,7 @@ def apply_orphan_strategy
# ... make al children root if orphan strategy is rootify
if self.class.orphan_strategy == :rootify
descendants.each do |descendant|
- descendant.update_attributes descendant.class.ancestry_column => descendant.ancestry == child_ancestry ? nil : descendant.ancestry.gsub(/^#{child_ancestry}\//, '')
+ descendant.update_attributes descendant.class.ancestry_column => (if descendant.ancestry == child_ancestry then nil else descendant.ancestry.gsub(/^#{child_ancestry}\//, '') end)
end
# ... destroy all descendants if orphan strategy is destroy
elsif self.class.orphan_strategy == :destroy
@@ -180,7 +247,7 @@ def child_ancestry
# New records cannot have children
raise Ancestry::AncestryException.new('No child ancestry for new record. Save record before performing tree operations.') if new_record?
- self.send("#{self.class.ancestry_column}_was").blank? ? id.to_s : "#{self.send "#{self.class.ancestry_column}_was"}/#{id}"
+ if self.send("#{self.class.ancestry_column}_was").blank? then id.to_s else "#{self.send "#{self.class.ancestry_column}_was"}/#{id}" end
end
# Ancestors
@@ -192,42 +259,54 @@ def ancestor_conditions
{:id => ancestor_ids}
end
- def ancestors
- self.class.scoped :conditions => ancestor_conditions
+ def ancestors depth_options = {}
+ self.class.scope_depth(depth_options, depth).scoped :conditions => ancestor_conditions, :order => self.class.ancestry_column
end
def path_ids
ancestor_ids + [id]
end
- def path
- ancestors + [self]
+ def path_conditions
+ {:id => path_ids}
+ end
+
+ def path depth_options = {}
+ self.class.scope_depth(depth_options, depth).scoped :conditions => path_conditions, :order => self.class.ancestry_column
+ end
+
+ def depth
+ ancestor_ids.size
+ end
+
+ def cache_depth
+ write_attribute self.class.depth_cache_column, depth
end
# Parent
def parent= parent
- write_attribute(self.class.ancestry_column, parent.blank? ? nil : parent.child_ancestry)
+ write_attribute(self.class.ancestry_column, if parent.blank? then nil else parent.child_ancestry end)
end
def parent_id= parent_id
- self.parent = parent_id.blank? ? nil : self.class.find(parent_id)
+ self.parent = if parent_id.blank? then nil else self.class.find(parent_id) end
end
def parent_id
- ancestor_ids.empty? ? nil : ancestor_ids.last
+ if ancestor_ids.empty? then nil else ancestor_ids.last end
end
def parent
- parent_id.blank? ? nil : self.class.find(parent_id)
+ if parent_id.blank? then nil else self.class.find(parent_id) end
end
# Root
def root_id
- ancestor_ids.empty? ? id : ancestor_ids.first
+ if ancestor_ids.empty? then id else ancestor_ids.first end
end
def root
- root_id == id ? self : self.class.find(root_id)
+ if root_id == id then self else self.class.find(root_id) end
end
def is_root?
@@ -281,20 +360,25 @@ def descendant_conditions
["#{self.class.ancestry_column} like ? or #{self.class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry]
end
- def descendants
- self.class.scoped :conditions => descendant_conditions
+ def descendants depth_options = {}
+ self.class.scope_depth(depth_options, depth).scoped :conditions => descendant_conditions
end
- def descendant_ids
- descendants.all(:select => :id).collect(&:id)
+ def descendant_ids depth_options = {}
+ descendants(depth_options).all(:select => :id).collect(&:id)
end
- def subtree
- [self] + descendants
+ # Subtree
+ def subtree_conditions
+ ["id = ? or #{self.class.ancestry_column} like ? or #{self.class.ancestry_column} = ?", self.id, "#{child_ancestry}/%", child_ancestry]
+ end
+
+ def subtree depth_options = {}
+ self.class.scope_depth(depth_options, depth).scoped :conditions => subtree_conditions
end
- def subtree_ids
- [self.id] + descendant_ids
+ def subtree_ids depth_options = {}
+ subtree(depth_options).all(:select => :id).collect(&:id)
end
end
end
View
1  rails/init.rb
@@ -1 +0,0 @@
-require 'ancestry'
View
185 test/acts_as_tree_test.rb
@@ -1,13 +1,16 @@
require File.dirname(__FILE__) + '/test_helper.rb'
class TestNode < ActiveRecord::Base
- acts_as_tree
+ acts_as_tree :cache_depth => true, :depth_cache_column => :depth_cache
end
class AlternativeTestNode < ActiveRecord::Base
acts_as_tree :ancestry_column => :alternative_ancestry, :orphan_strategy => :rootify
end
+class ParentIdTestNode < ActiveRecord::Base
+end
+
class ActsAsTreeTest < ActiveSupport::TestCase
load_schema
@@ -92,6 +95,7 @@ def test_tree_navigation
assert_equal [], lvl0_node.ancestors
assert_equal [lvl0_node.id], lvl0_node.path_ids
assert_equal [lvl0_node], lvl0_node.path
+ assert_equal 0, lvl0_node.depth
# Parent assertions
assert_equal nil, lvl0_node.parent_id
assert_equal nil, lvl0_node.parent
@@ -123,6 +127,7 @@ def test_tree_navigation
assert_equal [lvl0_node], lvl1_node.ancestors
assert_equal [lvl0_node.id, lvl1_node.id], lvl1_node.path_ids
assert_equal [lvl0_node, lvl1_node], lvl1_node.path
+ assert_equal 1, lvl1_node.depth
# Parent assertions
assert_equal lvl0_node.id, lvl1_node.parent_id
assert_equal lvl0_node, lvl1_node.parent
@@ -154,6 +159,7 @@ def test_tree_navigation
assert_equal [lvl0_node, lvl1_node], lvl2_node.ancestors
assert_equal [lvl0_node.id, lvl1_node.id, lvl2_node.id], lvl2_node.path_ids
assert_equal [lvl0_node, lvl1_node, lvl2_node], lvl2_node.path
+ assert_equal 2, lvl2_node.depth
# Parent assertions
assert_equal lvl1_node.id, lvl2_node.parent_id
assert_equal lvl1_node, lvl2_node.parent
@@ -282,43 +288,43 @@ def test_integrity_checking
# Check that there are no errors on a valid data set
setup_test_nodes(TestNode, 3, 3)
assert_nothing_raised do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
# Check detection of invalid format for ancestry column
setup_test_nodes(TestNode, 3, 3).first.first.update_attribute TestNode.ancestry_column, 'invalid_ancestry'
assert_raise Ancestry::AncestryIntegrityException do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
# Check detection of non-existent ancestor
setup_test_nodes(TestNode, 3, 3).first.first.update_attribute TestNode.ancestry_column, 35
assert_raise Ancestry::AncestryIntegrityException do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
# Check detection of cyclic ancestry
node = setup_test_nodes(TestNode, 3, 3).first.first
node.update_attribute TestNode.ancestry_column, node.id
assert_raise Ancestry::AncestryIntegrityException do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
# Check detection of conflicting parent id
TestNode.destroy_all
TestNode.create!(TestNode.ancestry_column => TestNode.create!(TestNode.ancestry_column => TestNode.create!(TestNode.ancestry_column => nil).id).id)
assert_raise Ancestry::AncestryIntegrityException do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
end
def assert_integrity_restoration
assert_raise Ancestry::AncestryIntegrityException do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
- TestNode.restore_ancestry_integrity
+ TestNode.restore_ancestry_integrity!
assert_nothing_raised do
- TestNode.check_ancestry_integrity
+ TestNode.check_ancestry_integrity!
end
end
@@ -382,4 +388,165 @@ def test_validate_ancestry_exclude_self
parent.update_attributes! :parent => child
end
end
+
+ def test_depth_caching
+ roots = setup_test_nodes TestNode, 3, 3
+ roots.each do |lvl0_node, lvl0_children|
+ assert_equal 0, lvl0_node.depth_cache
+ lvl0_children.each do |lvl1_node, lvl1_children|
+ assert_equal 1, lvl1_node.depth_cache
+ lvl1_children.each do |lvl2_node, lvl2_children|
+ assert_equal 2, lvl2_node.depth_cache
+ end
+ end
+ end
+ end
+
+ def test_depth_scopes
+ setup_test_nodes TestNode, 4, 4
+ TestNode.before_depth(2).all? { |node| assert node.depth < 2 }
+ TestNode.to_depth(2).all? { |node| assert node.depth <= 2 }
+ TestNode.at_depth(2).all? { |node| assert node.depth == 2 }
+ TestNode.from_depth(2).all? { |node| assert node.depth >= 2 }
+ TestNode.after_depth(2).all? { |node| assert node.depth > 2 }
+ end
+
+ def test_depth_scopes_unavailable
+ assert_raise Ancestry::AncestryException do
+ AlternativeTestNode.before_depth(1)
+ AlternativeTestNode.to_depth(1)
+ AlternativeTestNode.at_depth(1)
+ AlternativeTestNode.from_depth(1)
+ AlternativeTestNode.after_depth(1)
+ end
+ end
+
+ def test_invalid_acts_as_tree_options
+ assert_raise Ancestry::AncestryException do
+ Class.new(ActiveRecord::Base).acts_as_tree :this_option_doesnt_exist => 42
+ end
+ assert_raise Ancestry::AncestryException do
+ Class.new(ActiveRecord::Base).acts_as_tree :not_a_hash
+ end
+ end
+
+ def test_build_ancestry_from_parent_ids
+ [ParentIdTestNode.create!].each do |parent|
+ (Array.new(5) { ParentIdTestNode.create! :parent_id => parent.id }).each do |parent|
+ (Array.new(5) { ParentIdTestNode.create! :parent_id => parent.id }).each do |parent|
+ (Array.new(5) { ParentIdTestNode.create! :parent_id => parent.id })
+ end
+ end
+ end
+
+ # Assert all nodes where created
+ assert_equal 156, ParentIdTestNode.count
+
+ ParentIdTestNode.acts_as_tree
+ ParentIdTestNode.build_ancestry_from_parent_ids!
+
+ # Assert ancestry integirty
+ assert_nothing_raised do
+ ParentIdTestNode.check_ancestry_integrity!
+ end
+
+ roots = ParentIdTestNode.roots.all
+ # Assert single root node
+ assert_equal 1, roots.size
+
+ # Assert it has 5 children
+ roots.each do |parent|
+ assert 5, parent.children.count
+ parent.children.each do |parent|
+ assert 5, parent.children.count
+ parent.children.each do |parent|
+ assert 5, parent.children.count
+ parent.children.each do |parent|
+ assert 0, parent.children.count
+ end
+ end
+ end
+ end
+ end
+
+ def test_rebuild_depth_cache
+ setup_test_nodes TestNode, 3, 3
+ TestNode.find_by_sql("update test_nodes set depth_cache = null;")
+
+ # Assert cache was emptied correctly
+ TestNode.all.each do |test_node|
+ assert_equal nil, test_node.depth_cache
+ end
+
+ # Rebuild cache
+ TestNode.rebuild_depth_cache!
+
+ # Assert cache was rebuild correctly
+ TestNode.all.each do |test_node|
+ assert_equal test_node.depth, test_node.depth_cache
+ end
+ end
+
+ def test_exception_when_rebuilding_depth_cache_for_model_without_depth_caching
+ assert_raise Ancestry::AncestryException do
+ AlternativeTestNode.rebuild_depth_cache!
+ end
+ end
+
+ def test_descendants_with_depth_constraints
+ setup_test_nodes TestNode, 4, 4
+
+ assert_equal 4, TestNode.roots.first.descendants(:before_depth => 2).count
+ assert_equal 20, TestNode.roots.first.descendants(:to_depth => 2).count
+ assert_equal 16, TestNode.roots.first.descendants(:at_depth => 2).count
+ assert_equal 80, TestNode.roots.first.descendants(:from_depth => 2).count
+ assert_equal 64, TestNode.roots.first.descendants(:after_depth => 2).count
+ end
+
+ def test_subtree_with_depth_constraints
+ setup_test_nodes TestNode, 4, 4
+
+ assert_equal 5, TestNode.roots.first.subtree(:before_depth => 2).count
+ assert_equal 21, TestNode.roots.first.subtree(:to_depth => 2).count
+ assert_equal 16, TestNode.roots.first.subtree(:at_depth => 2).count
+ assert_equal 80, TestNode.roots.first.subtree(:from_depth => 2).count
+ assert_equal 64, TestNode.roots.first.subtree(:after_depth => 2).count
+ end
+
+
+ def test_ancestors_with_depth_constraints
+ node1 = TestNode.create!
+ node2 = node1.children.create!
+ node3 = node2.children.create!
+ node4 = node3.children.create!
+ node5 = node4.children.create!
+ leaf = node5.children.create!
+
+ assert_equal [node1, node2, node3], leaf.ancestors(:before_depth => -2)
+ assert_equal [node1, node2, node3, node4], leaf.ancestors(:to_depth => -2)
+ assert_equal [node4], leaf.ancestors(:at_depth => -2)
+ assert_equal [node4, node5], leaf.ancestors(:from_depth => -2)
+ assert_equal [node5], leaf.ancestors(:after_depth => -2)
+ end
+
+ def test_path_with_depth_constraints
+ node1 = TestNode.create!
+ node2 = node1.children.create!
+ node3 = node2.children.create!
+ node4 = node3.children.create!
+ node5 = node4.children.create!
+ leaf = node5.children.create!
+
+ assert_equal [node1, node2, node3], leaf.path(:before_depth => -2)
+ assert_equal [node1, node2, node3, node4], leaf.path(:to_depth => -2)
+ assert_equal [node4], leaf.path(:at_depth => -2)
+ assert_equal [node4, node5, leaf], leaf.path(:from_depth => -2)
+ assert_equal [node5, leaf], leaf.path(:after_depth => -2)
+ end
+
+ def test_exception_on_unknown_depth_column
+ assert_raise Ancestry::AncestryException do
+ TestNode.create!.subtree(:this_is_not_a_valid_depth_option => 42)
+ end
+ end
end
View
10 test/schema.rb
@@ -1,9 +1,19 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :test_nodes, :force => true do |t|
t.string :ancestry
+ t.integer :depth_cache
end
create_table :alternative_test_nodes, :force => true do |t|
t.string :alternative_ancestry
end
+
+ create_table :other_test_nodes, :force => true do |t|
+ t.string :ancestry
+ end
+
+ create_table :parent_id_test_nodes, :force => true do |t|
+ t.string :ancestry
+ t.integer :parent_id
+ end
end
View
2  test/test_helper.rb
@@ -27,5 +27,5 @@ def load_schema
end
ActiveRecord::Base.establish_connection(config[db_adapter])
load(File.dirname(__FILE__) + "/schema.rb")
- require File.dirname(__FILE__) + '/../rails/init.rb'
+ require File.dirname(__FILE__) + '/../init.rb'
end
View
1  uninstall.rb
@@ -1 +0,0 @@
-# Uninstall hook code here
Please sign in to comment.
Something went wrong with that request. Please try again.