From ecea76f4c02582f5224efb8f3b3391ed1b8bad1c Mon Sep 17 00:00:00 2001 From: Stefan Kroes Date: Sat, 27 Feb 2010 17:45:41 +0100 Subject: [PATCH] Version 1.2.0 --- README.rdoc | 39 +- ancestry.gemspec | 4 +- lib/ancestry.rb | 2 +- lib/ancestry/acts_as_tree.rb | 426 ------------------ lib/ancestry/class_methods.rb | 127 ++++++ lib/ancestry/exceptions.rb | 7 + lib/ancestry/has_ancestry.rb | 81 ++++ lib/ancestry/instance_methods.rb | 209 +++++++++ ...s_as_tree_test.rb => has_ancestry_test.rb} | 53 ++- test/schema.rb | 4 + 10 files changed, 505 insertions(+), 447 deletions(-) delete mode 100644 lib/ancestry/acts_as_tree.rb create mode 100644 lib/ancestry/class_methods.rb create mode 100644 lib/ancestry/exceptions.rb create mode 100644 lib/ancestry/has_ancestry.rb create mode 100644 lib/ancestry/instance_methods.rb rename test/{acts_as_tree_test.rb => has_ancestry_test.rb} (89%) diff --git a/README.rdoc b/README.rdoc index 0c02693f..091f24da 100644 --- a/README.rdoc +++ b/README.rdoc @@ -21,10 +21,14 @@ To apply Ancestry to any ActiveRecord model, follow these simple steps: - Migrate your database: rake db:migrate 3. Add ancestry to your model - - Add to app/models/[model].rb: acts_as_tree + - Add to app/models/[model].rb: has_ancestry Your model is now a tree! += Using acts_as_tree instead of has_ancestry + +In version 1.2.0 the acts_as_tree method was renamed to has_ancestry in order to allow usage of both the acts_as_tree gem and the ancestry gem in a single application. To not break backwards compatibility, the has_ancestry method is aliased with acts_as_tree if ActiveRecord::Base does not respond to acts_as_tree. acts_as_tree will continue to be supported in the future as I personally prefer it. + = Organising records into a tree You can use the parent attribute to organise your records into a tree. If you have the id of the record you want to use as a parent and don't want to fetch it, you can also use parent_id. Like any virtual model attributes, parent and parent_id can be set using parent= and parent_id= on a record or by including them in the hash passed to new, create, create!, update_attributes and update_attributes!. For example: @@ -62,9 +66,9 @@ To navigate an Ancestry model, use the following methods on any instance / recor 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 -= Options for acts_as_tree += Options for has_ancestry -The acts_as_tree methods supports the following options: +The has_ancestry methods supports the following 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: @@ -91,6 +95,7 @@ For convenience, a couple of named scopes are included at the class level: 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 + subtree_of(node) Subtree 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: @@ -102,7 +107,7 @@ Thanks to some convenient rails magic, it is even possible to create nodes throu = Selecting nodes by depth -When depth caching is enabled (see acts_as_tree options), five more named scopes can be used to select nodes on their depth: +When depth caching is enabled (see has_ancestry options), five more named scopes can be used to select nodes on their depth: 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) @@ -146,9 +151,13 @@ The arrange method also works on a scoped class, for example: TreeNode.find_by_name('Crunchy').subtree.arrange +The arrange method takes ActiveRecord find options. If you want your hashes to be ordered, you should pass the order to the arrange method instead of to the scope. This only works for Ruby 1.9 and later since before that hashes weren't ordered. For example: + + TreeNode.find_by_name('Crunchy').subtree.arrange(:order => :name) + = 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, use the build_ancestry_from_parent_ids! method on your ancestry model. These steps provide a more detailed explanation: +Most current tree plugins use a parent_id column (has_ancestry, awesome_nested_set, better_nested_set, acts_as_nested_set). With ancestry its easy to migrate from any of these plugins, to do so, use the build_ancestry_from_parent_ids! method on your ancestry model. These steps provide a more detailed explanation: 1. Add ancestry column to your table - Create migration: ./script/generate migration add_ancestry_to_[table] ancestry:string @@ -163,7 +172,7 @@ Most current tree plugins use a parent_id column (acts_as_tree, awesome_nested_s 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 + - Add to app/models/[model].rb: has_ancestry 4. Generate ancestry columns - In './script.console': [model].build_ancestry_from_parent_ids! @@ -206,7 +215,7 @@ Additionally, if you think something is wrong with your depth cache: = 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'. +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/has_ancestry_test.rb'. = Internals @@ -218,8 +227,18 @@ The materialised path pattern requires Ancestry to use a 'like' condition in ord = Version history -The latest and recommended version of ancestry is 1.1.4. 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. - +The latest and recommended version of ancestry is 1.2.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.2.0 (2009-11-07) + - Removed some duplication in has_ancestry + - Cleaned up plugin pattern according to http://yehudakatz.com/2009/11/12/better-ruby-idioms/ + - Moved parts of ancestry into seperate files + - Made it possible to pass options into the arrange method + - Renamed acts_as_tree to has_ancestry + - Aliased has_ancestry as acts_as_tree if acts_as_tree is available + - Added subtree_of scope + - Updated ordered_by_ancestry scope to support Microsoft SQL Server + - Added empty hash as parameter to exists? calls for older ActiveRecord versions - Version 1.1.4 (2009-11-07) - Thanks to a patch from tom taylor, Ancestry now works with different primary keys - Version 1.1.3 (2009-11-01) @@ -248,7 +267,7 @@ The latest and recommended version of ancestry is 1.1.4. The three numbers of ea - 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. + - Since these functions should only be used from ./script/console and not from your application, 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 diff --git a/ancestry.gemspec b/ancestry.gemspec index 7582220f..85898f55 100644 --- a/ancestry.gemspec +++ b/ancestry.gemspec @@ -5,8 +5,8 @@ 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.1.4' - s.date = '2009-11-07' + s.version = '1.2.0' + s.date = '2010-01-27' s.author = 'Stefan Kroes' s.email = 's.a.kroes@gmail.com' diff --git a/lib/ancestry.rb b/lib/ancestry.rb index 0e04d92b..a7a4a44d 100644 --- a/lib/ancestry.rb +++ b/lib/ancestry.rb @@ -1 +1 @@ -require 'ancestry/acts_as_tree' +require 'ancestry/has_ancestry' diff --git a/lib/ancestry/acts_as_tree.rb b/lib/ancestry/acts_as_tree.rb deleted file mode 100644 index 6014b65d..00000000 --- a/lib/ancestry/acts_as_tree.rb +++ /dev/null @@ -1,426 +0,0 @@ -module Ancestry - class AncestryException < RuntimeError - end - - class AncestryIntegrityException < AncestryException - end - - def self.included base - base.send :extend, ClassMethods - end - - 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 option for acts_as_tree: #{key.inspect} => #{value.inspect}.") - end - end - - # Include instance methods - send :include, InstanceMethods - - # Include dynamic class methods - send :extend, DynamicClassMethods - - # Create ancestry column accessor and set to option or default - self.cattr_accessor :ancestry_column - self.ancestry_column = options[:ancestry_column] || :ancestry - - # Create orphan strategy accessor and set to option or default (writer comes from DynamicClassMethods) - self.cattr_reader :orphan_strategy - self.orphan_strategy = options[:orphan_strategy] || :destroy - - # Save self as base class (for STI) - self.cattr_accessor :base_class - self.base_class = self - - # Validate format of ancestry column value - validates_format_of ancestry_column, :with => /\A[0-9]+(\/[0-9]+)*\Z/, :allow_nil => true - - # 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 :ordered_by_ancestry, :order => "#{ancestry_column} is not null, #{ancestry_column}" - - # Update descendants with new ancestry before save - before_save :update_descendants_with_new_ancestry - - # Apply orphan strategy before destroy - before_destroy :apply_orphan_strategy - - # Create ancestry column accessor and set to option or default - if options[:cache_depth] - # Create accessor for column name and set to option or default - 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_validation :cache_depth - - # Validate depth column - validates_numericality_of depth_cache_column, :greater_than_or_equal_to => 0, :only_integer => true, :allow_nil => false - 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]} - } - end - end - - module DynamicClassMethods - # Fetch tree node if necessary - def to_node object - if object.is_a?(self.base_class) then object else find(object) end - end - - # Scope on relative depth options - def scope_depth depth_options, depth - depth_options.inject(self.base_class) 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 - class_variable_set :@@orphan_strategy, orphan_strategy - else - raise AncestryException.new("Invalid orphan strategy, valid ones are :rootify, :restrict and :destroy.") - end - end - - # Arrangement - def arrange - # Get all nodes ordered by ancestry and start sorting them into an empty hash - self.base_class.ordered_by_ancestry.all.inject({}) do |arranged_nodes, node| - # Find the insertion point for that node by going through its ancestors - node.ancestor_ids.inject(arranged_nodes) do |insertion_point, ancestor_id| - insertion_point.each do |parent, children| - # Change the insertion point to children if node is a descendant of this parent - insertion_point = children if ancestor_id == parent.id - end; insertion_point - end[node] = {}; arranged_nodes - end - end - - # Integrity checking - def check_ancestry_integrity! - parents = {} - # For each node ... - self.base_class.all.each do |node| - # ... check validity of ancestry column - if !node.valid? and node.errors.invalid?(node.class.ancestry_column) - raise AncestryIntegrityException.new("Invalid format for ancestry column of node #{node.id}: #{node.read_attribute node.ancestry_column}.") - end - # ... check that all ancestors exist - node.ancestor_ids.each do |ancestor_id| - unless exists? ancestor_id - raise AncestryIntegrityException.new("Reference to non-existent node in node #{node.id}: #{ancestor_id}.") - end - end - # ... check that all node parents are consistent with values observed earlier - node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id| - parents[node_id] = parent_id unless parents.has_key? node_id - unless parents[node_id] == parent_id - raise AncestryIntegrityException.new("Conflicting parent id in node #{node.id}: #{parent_id || 'nil'} for node #{node_id}, expecting #{parents[node_id] || 'nil'}") - end - end - end - end - - # Integrity restoration - def restore_ancestry_integrity! - parents = {} - # For each node ... - self.base_class.all.each do |node| - # ... set its ancestry to nil if invalid - if node.errors.invalid? node.class.ancestry_column - node.without_ancestry_callbacks do - node.update_attributes :ancestry => nil - end - end - # ... save parent of this node in parents array if it exists - parents[node.id] = node.parent_id if exists? node.parent_id - - # Reset parent id in array to nil if it introduces a cycle - parent = parents[node.id] - until parent.nil? || parent == node.id - parent = parents[parent] - end - parents[node.id] = nil if parent == node.id - end - # For each node ... - self.base_class.all.each do |node| - # ... rebuild ancestry from parents array - ancestry, parent = nil, parents[node.id] - until parent.nil? - ancestry, parent = if ancestry.nil? then parent else "#{parent}/#{ancestry}" end, parents[parent] - end - node.without_ancestry_callbacks do - node.update_attributes node.ancestry_column => ancestry - end - end - end - - # Build ancestry from parent id's for migration purposes - def build_ancestry_from_parent_ids! parent_id = nil, ancestry = nil - self.base_class.all(:conditions => {:parent_id => parent_id}).each do |node| - node.without_ancestry_callbacks do - node.update_attribute ancestry_column, ancestry - end - build_ancestry_from_parent_ids! node.id, if ancestry.nil? then "#{node.id}" else "#{ancestry}/#{node.id}" end - end - end - - # Rebuild depth cache if it got corrupted or if depth caching was just turned on - def rebuild_depth_cache! - raise Ancestry::AncestryException.new("Cannot rebuild depth cache for model without depth caching.") unless respond_to? :depth_cache_column - self.base_class.all.each do |node| - node.update_attribute depth_cache_column, node.depth - end - end - end - - module InstanceMethods - # Validate that the ancestors don't include itself - def ancestry_exclude_self - errors.add_to_base "#{self.class.name.humanize} cannot be a descendant of itself." if ancestor_ids.include? self.id - end - - # Update descendants with new ancestry - def update_descendants_with_new_ancestry - # Skip this if callbacks are disabled - unless ancestry_callbacks_disabled? - # If node is valid, not a new record and ancestry was updated ... - if changed.include?(self.base_class.ancestry_column.to_s) && !new_record? && valid? - # ... for each descendant ... - descendants.each do |descendant| - # ... replace old ancestry with new ancestry - descendant.without_ancestry_callbacks do - descendant.update_attributes( - self.base_class.ancestry_column => - descendant.read_attribute(descendant.class.ancestry_column).gsub( - /^#{self.child_ancestry}/, - if read_attribute(self.class.ancestry_column).blank? then id.to_s else "#{read_attribute self.class.ancestry_column }/#{id}" end - ) - ) - end - end - end - end - end - - # Apply orphan strategy - def apply_orphan_strategy - # Skip this if callbacks are disabled - unless ancestry_callbacks_disabled? - # If this isn't a new record ... - unless new_record? - # ... make al children root if orphan strategy is rootify - if self.base_class.orphan_strategy == :rootify - descendants.each do |descendant| - descendant.without_ancestry_callbacks do - descendant.update_attributes descendant.class.ancestry_column => (if descendant.ancestry == child_ancestry then nil else descendant.ancestry.gsub(/^#{child_ancestry}\//, '') end) - end - end - # ... destroy all descendants if orphan strategy is destroy - elsif self.base_class.orphan_strategy == :destroy - descendants.all.each do |descendant| - descendant.without_ancestry_callbacks do - descendant.destroy - end - end - # ... throw an exception if it has children and orphan strategy is restrict - elsif self.base_class.orphan_strategy == :restrict - raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless? - end - end - end - end - - # The ancestry value for this record's children - 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? - - if self.send("#{self.base_class.ancestry_column}_was").blank? then id.to_s else "#{self.send "#{self.base_class.ancestry_column}_was"}/#{id}" end - end - - # Ancestors - def ancestor_ids - read_attribute(self.base_class.ancestry_column).to_s.split('/').map(&:to_i) - end - - def ancestor_conditions - {self.base_class.primary_key => ancestor_ids} - end - - def ancestors depth_options = {} - self.base_class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => ancestor_conditions - end - - def path_ids - ancestor_ids + [id] - end - - def path_conditions - {self.base_class.primary_key => path_ids} - end - - def path depth_options = {} - self.base_class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => path_conditions - end - - def depth - ancestor_ids.size - end - - def cache_depth - write_attribute self.base_class.depth_cache_column, depth - end - - # Parent - def parent= parent - write_attribute(self.base_class.ancestry_column, if parent.blank? then nil else parent.child_ancestry end) - end - - def parent_id= parent_id - self.parent = if parent_id.blank? then nil else self.base_class.find(parent_id) end - end - - def parent_id - if ancestor_ids.empty? then nil else ancestor_ids.last end - end - - def parent - if parent_id.blank? then nil else self.base_class.find(parent_id) end - end - - # Root - def root_id - if ancestor_ids.empty? then id else ancestor_ids.first end - end - - def root - if root_id == id then self else self.base_class.find(root_id) end - end - - def is_root? - read_attribute(self.base_class.ancestry_column).blank? - end - - # Children - def child_conditions - {self.base_class.ancestry_column => child_ancestry} - end - - def children - self.base_class.scoped :conditions => child_conditions - end - - def child_ids - children.all(:select => self.base_class.primary_key).map(&self.base_class.primary_key.to_sym) - end - - def has_children? - self.children.exists? - end - - def is_childless? - !has_children? - end - - # Siblings - def sibling_conditions - {self.base_class.ancestry_column => read_attribute(self.base_class.ancestry_column)} - end - - def siblings - self.base_class.scoped :conditions => sibling_conditions - end - - def sibling_ids - siblings.all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) - end - - def has_siblings? - self.siblings.count > 1 - end - - def is_only_child? - !has_siblings? - end - - # Descendants - def descendant_conditions - ["#{self.base_class.ancestry_column} like ? or #{self.base_class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry] - end - - def descendants depth_options = {} - self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => descendant_conditions - end - - def descendant_ids depth_options = {} - descendants(depth_options).all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) - end - - # Subtree - def subtree_conditions - ["#{self.base_class.primary_key} = ? or #{self.base_class.ancestry_column} like ? or #{self.base_class.ancestry_column} = ?", self.id, "#{child_ancestry}/%", child_ancestry] - end - - def subtree depth_options = {} - self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => subtree_conditions - end - - def subtree_ids depth_options = {} - subtree(depth_options).all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) - end - - # Callback disabling - def without_ancestry_callbacks - @disable_ancestry_callbacks = true - yield - @disable_ancestry_callbacks = false - end - - def ancestry_callbacks_disabled? - !!@disable_ancestry_callbacks - end - end -end - -ActiveRecord::Base.send :include, Ancestry \ No newline at end of file diff --git a/lib/ancestry/class_methods.rb b/lib/ancestry/class_methods.rb new file mode 100644 index 00000000..45e8c79f --- /dev/null +++ b/lib/ancestry/class_methods.rb @@ -0,0 +1,127 @@ +module Ancestry + module ClassMethods + # Fetch tree node if necessary + def to_node object + if object.is_a?(self.base_class) then object else find(object) end + end + + # Scope on relative depth options + def scope_depth depth_options, depth + depth_options.inject(self.base_class) 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 + class_variable_set :@@orphan_strategy, orphan_strategy + else + raise Ancestry::AncestryException.new("Invalid orphan strategy, valid ones are :rootify, :restrict and :destroy.") + end + end + + # Arrangement + def arrange options = {} + scope = + if options[:order].nil? + self.base_class.ordered_by_ancestry + else + self.base_class.ordered_by_ancestry_and options.delete(:order) + end + # Get all nodes ordered by ancestry and start sorting them into an empty hash + scope.all(options).inject({}) do |arranged_nodes, node| + # Find the insertion point for that node by going through its ancestors + node.ancestor_ids.inject(arranged_nodes) do |insertion_point, ancestor_id| + insertion_point.each do |parent, children| + # Change the insertion point to children if node is a descendant of this parent + insertion_point = children if ancestor_id == parent.id + end; insertion_point + end[node] = {}; arranged_nodes + end + end + + # Integrity checking + def check_ancestry_integrity! + parents = {} + # For each node ... + self.base_class.all.each do |node| + # ... check validity of ancestry column + if !node.valid? and node.errors.invalid?(node.class.ancestry_column) + raise Ancestry::AncestryIntegrityException.new("Invalid format for ancestry column of node #{node.id}: #{node.read_attribute node.ancestry_column}.") + end + # ... check that all ancestors exist + node.ancestor_ids.each do |ancestor_id| + unless exists? ancestor_id + raise Ancestry::AncestryIntegrityException.new("Reference to non-existent node in node #{node.id}: #{ancestor_id}.") + end + end + # ... check that all node parents are consistent with values observed earlier + node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id| + parents[node_id] = parent_id unless parents.has_key? node_id + unless parents[node_id] == parent_id + raise Ancestry::AncestryIntegrityException.new("Conflicting parent id in node #{node.id}: #{parent_id || 'nil'} for node #{node_id}, expecting #{parents[node_id] || 'nil'}") + end + end + end + end + + # Integrity restoration + def restore_ancestry_integrity! + parents = {} + # For each node ... + self.base_class.all.each do |node| + # ... set its ancestry to nil if invalid + if node.errors.invalid? node.class.ancestry_column + node.without_ancestry_callbacks do + node.update_attributes :ancestry => nil + end + end + # ... save parent of this node in parents array if it exists + parents[node.id] = node.parent_id if exists? node.parent_id + + # Reset parent id in array to nil if it introduces a cycle + parent = parents[node.id] + until parent.nil? || parent == node.id + parent = parents[parent] + end + parents[node.id] = nil if parent == node.id + end + # For each node ... + self.base_class.all.each do |node| + # ... rebuild ancestry from parents array + ancestry, parent = nil, parents[node.id] + until parent.nil? + ancestry, parent = if ancestry.nil? then parent else "#{parent}/#{ancestry}" end, parents[parent] + end + node.without_ancestry_callbacks do + node.update_attributes node.ancestry_column => ancestry + end + end + end + + # Build ancestry from parent id's for migration purposes + def build_ancestry_from_parent_ids! parent_id = nil, ancestry = nil + self.base_class.all(:conditions => {:parent_id => parent_id}).each do |node| + node.without_ancestry_callbacks do + node.update_attribute ancestry_column, ancestry + end + build_ancestry_from_parent_ids! node.id, if ancestry.nil? then "#{node.id}" else "#{ancestry}/#{node.id}" end + end + end + + # Rebuild depth cache if it got corrupted or if depth caching was just turned on + def rebuild_depth_cache! + raise Ancestry::AncestryException.new("Cannot rebuild depth cache for model without depth caching.") unless respond_to? :depth_cache_column + self.base_class.all.each do |node| + node.update_attribute depth_cache_column, node.depth + end + end + end +end \ No newline at end of file diff --git a/lib/ancestry/exceptions.rb b/lib/ancestry/exceptions.rb new file mode 100644 index 00000000..9088d8e6 --- /dev/null +++ b/lib/ancestry/exceptions.rb @@ -0,0 +1,7 @@ +module Ancestry + class AncestryException < RuntimeError + end + + class AncestryIntegrityException < AncestryException + end +end \ No newline at end of file diff --git a/lib/ancestry/has_ancestry.rb b/lib/ancestry/has_ancestry.rb new file mode 100644 index 00000000..76dab59c --- /dev/null +++ b/lib/ancestry/has_ancestry.rb @@ -0,0 +1,81 @@ +require 'ancestry/class_methods' +require 'ancestry/instance_methods' +require 'ancestry/exceptions' + +class << ActiveRecord::Base + def has_ancestry options = {} + # Check options + raise Ancestry::AncestryException.new("Options for has_ancestry 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 Ancestry::AncestryException.new("Unknown option for has_ancestry: #{key.inspect} => #{value.inspect}.") + end + end + + # Include instance methods + include Ancestry::InstanceMethods + + # Include dynamic class methods + extend Ancestry::ClassMethods + + # Create ancestry column accessor and set to option or default + cattr_accessor :ancestry_column + self.ancestry_column = options[:ancestry_column] || :ancestry + + # Create orphan strategy accessor and set to option or default (writer comes from DynamicClassMethods) + cattr_reader :orphan_strategy + self.orphan_strategy = options[:orphan_strategy] || :destroy + + # Save self as base class (for STI) + cattr_accessor :base_class + self.base_class = self + + # Validate format of ancestry column value + validates_format_of ancestry_column, :with => /\A[0-9]+(\/[0-9]+)*\Z/, :allow_nil => true + + # 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 :subtree_of, lambda { |object| {:conditions => to_node(object).subtree_conditions} } + named_scope :siblings_of, lambda { |object| {:conditions => to_node(object).sibling_conditions} } + named_scope :ordered_by_ancestry, :order => "(case when #{ancestry_column} is null then 0 else 1 end), #{ancestry_column}" + named_scope :ordered_by_ancestry_and, lambda { |order| {:order => "(case when #{ancestry_column} is null then 0 else 1 end), #{ancestry_column}, #{order}"} } + + # Update descendants with new ancestry before save + before_save :update_descendants_with_new_ancestry + + # Apply orphan strategy before destroy + before_destroy :apply_orphan_strategy + + # Create ancestry column accessor and set to option or default + if options[:cache_depth] + # Create accessor for column name and set to option or default + 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_validation :cache_depth + + # Validate depth column + validates_numericality_of depth_cache_column, :greater_than_or_equal_to => 0, :only_integer => true, :allow_nil => false + end + + # Create named scopes for depth + {:before_depth => '<', :to_depth => '<=', :at_depth => '=', :from_depth => '>=', :after_depth => '>'}.each do |scope_name, operator| + named_scope scope_name, lambda { |depth| + raise Ancestry::AncestryException.new("Named scope '#{scope_name}' is only available when depth caching is enabled.") unless options[:cache_depth] + {:conditions => ["#{depth_cache_column} #{operator} ?", depth]} + } + end + end + + # Alias has_ancestry with acts_as_tree, if it's available. + if !respond_to?(:acts_as_tree) + alias_method :acts_as_tree, :has_ancestry + end +end \ No newline at end of file diff --git a/lib/ancestry/instance_methods.rb b/lib/ancestry/instance_methods.rb new file mode 100644 index 00000000..3a453d7e --- /dev/null +++ b/lib/ancestry/instance_methods.rb @@ -0,0 +1,209 @@ +module Ancestry + module InstanceMethods + # Validate that the ancestors don't include itself + def ancestry_exclude_self + errors.add_to_base "#{self.class.name.humanize} cannot be a descendant of itself." if ancestor_ids.include? self.id + end + + # Update descendants with new ancestry + def update_descendants_with_new_ancestry + # Skip this if callbacks are disabled + unless ancestry_callbacks_disabled? + # If node is valid, not a new record and ancestry was updated ... + if changed.include?(self.base_class.ancestry_column.to_s) && !new_record? && valid? + # ... for each descendant ... + descendants.each do |descendant| + # ... replace old ancestry with new ancestry + descendant.without_ancestry_callbacks do + descendant.update_attributes( + self.base_class.ancestry_column => + descendant.read_attribute(descendant.class.ancestry_column).gsub( + /^#{self.child_ancestry}/, + if read_attribute(self.class.ancestry_column).blank? then id.to_s else "#{read_attribute self.class.ancestry_column }/#{id}" end + ) + ) + end + end + end + end + end + + # Apply orphan strategy + def apply_orphan_strategy + # Skip this if callbacks are disabled + unless ancestry_callbacks_disabled? + # If this isn't a new record ... + unless new_record? + # ... make al children root if orphan strategy is rootify + if self.base_class.orphan_strategy == :rootify + descendants.each do |descendant| + descendant.without_ancestry_callbacks do + descendant.update_attributes descendant.class.ancestry_column => (if descendant.ancestry == child_ancestry then nil else descendant.ancestry.gsub(/^#{child_ancestry}\//, '') end) + end + end + # ... destroy all descendants if orphan strategy is destroy + elsif self.base_class.orphan_strategy == :destroy + descendants.all.each do |descendant| + descendant.without_ancestry_callbacks do + descendant.destroy + end + end + # ... throw an exception if it has children and orphan strategy is restrict + elsif self.base_class.orphan_strategy == :restrict + raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless? + end + end + end + end + + # The ancestry value for this record's children + 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? + + if self.send("#{self.base_class.ancestry_column}_was").blank? then id.to_s else "#{self.send "#{self.base_class.ancestry_column}_was"}/#{id}" end + end + + # Ancestors + def ancestor_ids + read_attribute(self.base_class.ancestry_column).to_s.split('/').map(&:to_i) + end + + def ancestor_conditions + {self.base_class.primary_key => ancestor_ids} + end + + def ancestors depth_options = {} + self.base_class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => ancestor_conditions + end + + def path_ids + ancestor_ids + [id] + end + + def path_conditions + {self.base_class.primary_key => path_ids} + end + + def path depth_options = {} + self.base_class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => path_conditions + end + + def depth + ancestor_ids.size + end + + def cache_depth + write_attribute self.base_class.depth_cache_column, depth + end + + # Parent + def parent= parent + write_attribute(self.base_class.ancestry_column, if parent.blank? then nil else parent.child_ancestry end) + end + + def parent_id= parent_id + self.parent = if parent_id.blank? then nil else self.base_class.find(parent_id) end + end + + def parent_id + if ancestor_ids.empty? then nil else ancestor_ids.last end + end + + def parent + if parent_id.blank? then nil else self.base_class.find(parent_id) end + end + + # Root + def root_id + if ancestor_ids.empty? then id else ancestor_ids.first end + end + + def root + if root_id == id then self else self.base_class.find(root_id) end + end + + def is_root? + read_attribute(self.base_class.ancestry_column).blank? + end + + # Children + def child_conditions + {self.base_class.ancestry_column => child_ancestry} + end + + def children + self.base_class.scoped :conditions => child_conditions + end + + def child_ids + children.all(:select => self.base_class.primary_key).map(&self.base_class.primary_key.to_sym) + end + + def has_children? + self.children.exists? {} + end + + def is_childless? + !has_children? + end + + # Siblings + def sibling_conditions + {self.base_class.ancestry_column => read_attribute(self.base_class.ancestry_column)} + end + + def siblings + self.base_class.scoped :conditions => sibling_conditions + end + + def sibling_ids + siblings.all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) + end + + def has_siblings? + self.siblings.count > 1 + end + + def is_only_child? + !has_siblings? + end + + # Descendants + def descendant_conditions + ["#{self.base_class.ancestry_column} like ? or #{self.base_class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry] + end + + def descendants depth_options = {} + self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => descendant_conditions + end + + def descendant_ids depth_options = {} + descendants(depth_options).all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) + end + + # Subtree + def subtree_conditions + ["#{self.base_class.primary_key} = ? or #{self.base_class.ancestry_column} like ? or #{self.base_class.ancestry_column} = ?", self.id, "#{child_ancestry}/%", child_ancestry] + end + + def subtree depth_options = {} + self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => subtree_conditions + end + + def subtree_ids depth_options = {} + subtree(depth_options).all(:select => self.base_class.primary_key).collect(&self.base_class.primary_key.to_sym) + end + + # Callback disabling + def without_ancestry_callbacks + @disable_ancestry_callbacks = true + yield + @disable_ancestry_callbacks = false + end + + def ancestry_callbacks_disabled? + !!@disable_ancestry_callbacks + end + end +end \ No newline at end of file diff --git a/test/acts_as_tree_test.rb b/test/has_ancestry_test.rb similarity index 89% rename from test/acts_as_tree_test.rb rename to test/has_ancestry_test.rb index b847a369..c8f66fe4 100644 --- a/test/acts_as_tree_test.rb +++ b/test/has_ancestry_test.rb @@ -1,11 +1,17 @@ require File.dirname(__FILE__) + '/test_helper.rb' +# Setup the required models for all test cases + class TestNode < ActiveRecord::Base - acts_as_tree :cache_depth => true, :depth_cache_column => :depth_cache + has_ancestry :cache_depth => true, :depth_cache_column => :depth_cache end class AlternativeTestNode < ActiveRecord::Base - acts_as_tree :ancestry_column => :alternative_ancestry, :orphan_strategy => :rootify + has_ancestry :ancestry_column => :alternative_ancestry, :orphan_strategy => :rootify +end + +class ActsAsTreeTestNode < ActiveRecord::Base + acts_as_tree end class ParentIdTestNode < ActiveRecord::Base @@ -71,7 +77,7 @@ def test_setting_invalid_orphan_strategy end def test_setup_test_nodes - [TestNode, AlternativeTestNode].each do |model| + [TestNode, AlternativeTestNode, ActsAsTreeTestNode].each do |model| roots = setup_test_nodes model, 3, 3 assert_equal Array, roots.class assert_equal 3, roots.length @@ -211,6 +217,9 @@ def test_named_scopes # Assertions for descendants_of named scope assert_equal test_node.descendants, TestNode.descendants_of(test_node) assert_equal test_node.descendants, TestNode.descendants_of(test_node.id) + # Assertions for subtree_of named scope + assert_equal test_node.subtree, TestNode.subtree_of(test_node) + assert_equal test_node.subtree, TestNode.subtree_of(test_node.id) # Assertions for siblings_of named scope assert_equal test_node.siblings, TestNode.siblings_of(test_node) assert_equal test_node.siblings, TestNode.siblings_of(test_node.id) @@ -427,12 +436,12 @@ def test_depth_scopes_unavailable end end - def test_invalid_acts_as_tree_options + def test_invalid_has_ancestry_options assert_raise Ancestry::AncestryException do - Class.new(ActiveRecord::Base).acts_as_tree :this_option_doesnt_exist => 42 + Class.new(ActiveRecord::Base).has_ancestry :this_option_doesnt_exist => 42 end assert_raise Ancestry::AncestryException do - Class.new(ActiveRecord::Base).acts_as_tree :not_a_hash + Class.new(ActiveRecord::Base).has_ancestry :not_a_hash end end @@ -448,7 +457,7 @@ def test_build_ancestry_from_parent_ids # Assert all nodes where created assert_equal 156, ParentIdTestNode.count - ParentIdTestNode.acts_as_tree + ParentIdTestNode.has_ancestry ParentIdTestNode.build_ancestry_from_parent_ids! # Assert ancestry integirty @@ -477,7 +486,7 @@ def test_build_ancestry_from_parent_ids def test_rebuild_depth_cache setup_test_nodes TestNode, 3, 3 - TestNode.find_by_sql("update test_nodes set depth_cache = null;") + TestNode.connection.execute("update test_nodes set depth_cache = null;") # Assert cache was emptied correctly TestNode.all.each do |test_node| @@ -568,4 +577,32 @@ def test_sti_support assert_equal [node1, node2, node3, node4], node5.ancestors assert_equal [node1, node2, node3, node4, node5], node5.path end + + def test_arrange_order_option + # In Ruby versions before 1.9 hashes aren't ordered so this doesn't make sense + unless RUBY_VERSION =~ /^1\.8/ + roots = setup_test_nodes TestNode, 3, 3 + descending_nodes_lvl0 = TestNode.arrange :order => 'id desc' + ascending_nodes_lvl0 = TestNode.arrange :order => 'id asc' + + descending_nodes_lvl0.keys.zip(ascending_nodes_lvl0.keys.reverse).each do |descending_node, ascending_node| + assert_equal descending_node, ascending_node + descending_nodes_lvl1 = descending_nodes_lvl0[descending_node] + ascending_nodes_lvl1 = ascending_nodes_lvl0[ascending_node] + descending_nodes_lvl1.keys.zip(ascending_nodes_lvl1.keys.reverse).each do |descending_node, ascending_node| + assert_equal descending_node, ascending_node + descending_nodes_lvl2 = descending_nodes_lvl1[descending_node] + ascending_nodes_lvl2 = ascending_nodes_lvl1[ascending_node] + descending_nodes_lvl2.keys.zip(ascending_nodes_lvl2.keys.reverse).each do |descending_node, ascending_node| + assert_equal descending_node, ascending_node + descending_nodes_lvl3 = descending_nodes_lvl2[descending_node] + ascending_nodes_lvl3 = ascending_nodes_lvl2[ascending_node] + descending_nodes_lvl3.keys.zip(ascending_nodes_lvl3.keys.reverse).each do |descending_node, ascending_node| + assert_equal descending_node, ascending_node + end + end + end + end + end + end end diff --git a/test/schema.rb b/test/schema.rb index 581aa539..9d265c6f 100644 --- a/test/schema.rb +++ b/test/schema.rb @@ -17,4 +17,8 @@ t.string :ancestry t.integer :parent_id end + + create_table :acts_as_tree_test_nodes, :force => true do |t| + t.string :ancestry + end end \ No newline at end of file