Permalink
Browse files

- Version 1.1.2 (2009-10-29)

  - Added validation for depth cache column
  - Added STI support (reported broken)
  • Loading branch information...
1 parent 41c344d commit e3ea26a54e1f609548e121bc281edf8bd6eba614 @stefankroes committed Oct 29, 2009
Showing with 95 additions and 57 deletions.
  1. +12 −3 README.rdoc
  2. +1 −1 ancestry.gemspec
  3. +59 −50 lib/ancestry/acts_as_tree.rb
  4. +19 −0 test/acts_as_tree_test.rb
  5. +3 −3 test/database.yml
  6. +1 −0 test/schema.rb
View
@@ -1,6 +1,6 @@
= Ancestry
-Ancestry is a gem/plugin that allows the records of a Ruby on Rails ActiveRecord model to be organised as a tree structure (or hierarchy). It uses 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, depth caching, depth constraints, easy migration from older plugins/gems, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.
+Ancestry is a gem/plugin that allows the records of a Ruby on Rails ActiveRecord model to be organised as a tree structure (or hierarchy). It uses 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 STI support, named_scopes, depth caching, depth constraints, easy migration from older plugins/gems, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.
= Installation
@@ -72,7 +72,9 @@ The acts_as_tree methods supports the following options:
: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!
+ If you turn depth_caching on for an existing model:
+ - Migrate: add_column [table], :ancestry_depth, :default => 0
+ - Build cache: TreeNode.rebuild_depth_cache!
:depth_cache_column Pass in a symbol to store depth cache in a different column
= (Named) Scopes
@@ -124,6 +126,10 @@ The depth scopes are also available through calls to descendants, descendant_ids
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.
+= STI support
+
+Ancestry works fine with STI. Just create a STI inheritance hierarchy and build an Ancestry tree from the different classes/models. All Ancestry relations that where described above will return nodes of any model type. If you do only want nodes of a specific subclass you'll have to add a condition on type for that.
+
= Arrangement
Ancestry can arrange an entire subtree into nested hashes for easy navigation after retrieval from the database. TreeNode.arrange could for example return:
@@ -212,8 +218,11 @@ 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.1. 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.1.2. 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.2 (2009-10-29)
+ - Added validation for depth cache column
+ - Added STI support (reported broken)
- Version 1.1.1 (2009-10-28)
- Fixed some parentheses warnings that where reported
- Fixed a reported issue with arrangement
View
@@ -5,7 +5,7 @@ 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.1'
+ s.version = '1.1.2'
s.date = '2009-10-29'
s.author = 'Stefan Kroes'
@@ -33,15 +33,41 @@ def acts_as_tree options = {}
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_save :cache_depth
+ 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
@@ -65,35 +91,18 @@ def acts_as_tree options = {}
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 :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
end
end
module DynamicClassMethods
# Fetch tree node if necessary
def to_node object
- if object.is_a?(self) then object else find(object) end
+ 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) do |scope, option|
+ 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
@@ -116,7 +125,7 @@ def orphan_strategy= orphan_strategy
# Arrangement
def arrange
# Get all nodes ordered by ancestry and start sorting them into an empty hash
- ordered_by_ancestry.all.inject({}) do |arranged_nodes, node|
+ 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|
@@ -131,7 +140,7 @@ def arrange
def check_ancestry_integrity!
parents = {}
# For each node ...
- all.each do |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}.")
@@ -156,7 +165,7 @@ def check_ancestry_integrity!
def restore_ancestry_integrity!
parents = {}
# For each node ...
- all.each do |node|
+ self.base_class.all.each do |node|
# ... set its ancestry to nil if invalid
if node.errors.invalid? node.class.ancestry_column
node.update_attributes :ancestry => nil
@@ -172,7 +181,7 @@ def restore_ancestry_integrity!
parents[node.id] = nil if parent == node.id
end
# For each node ...
- all.each do |node|
+ self.base_class.all.each do |node|
# ... rebuild ancestry from parents array
ancestry, parent = nil, parents[node.id]
until parent.nil?
@@ -184,7 +193,7 @@ def restore_ancestry_integrity!
# 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|
+ self.base_class.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
@@ -193,7 +202,7 @@ def build_ancestry_from_parent_ids! parent_id = nil, ancestry = nil
# 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
- all.each do |node|
+ self.base_class.all.each do |node|
node.update_attribute depth_cache_column, node.depth
end
end
@@ -208,12 +217,12 @@ def ancestry_exclude_self
# Update descendants with new ancestry
def update_descendants_with_new_ancestry
# If node is valid, not a new record and ancestry was updated ...
- if changed.include?(self.class.ancestry_column.to_s) && !new_record? && valid?
+ 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.update_attributes(
- self.class.ancestry_column =>
+ 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
@@ -228,15 +237,15 @@ def apply_orphan_strategy
# If this isn't a new record ...
unless new_record?
# ... make al children root if orphan strategy is rootify
- if self.class.orphan_strategy == :rootify
+ if self.base_class.orphan_strategy == :rootify
descendants.each do |descendant|
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
- self.class.destroy_all descendant_conditions
+ elsif self.base_class.orphan_strategy == :destroy
+ self.base_class.destroy_all descendant_conditions
# ... throw an exception if it has children and orphan strategy is restrict
- elsif self.class.orphan_strategy == :restrict
+ elsif self.base_class.orphan_strategy == :restrict
raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless?
end
end
@@ -247,20 +256,20 @@ 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.class.ancestry_column}_was").blank? then id.to_s else "#{self.send "#{self.class.ancestry_column}_was"}/#{id}" end
+ 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.class.ancestry_column).to_s.split('/').map(&:to_i)
+ read_attribute(self.base_class.ancestry_column).to_s.split('/').map(&:to_i)
end
def ancestor_conditions
{:id => ancestor_ids}
end
def ancestors depth_options = {}
- self.class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => ancestor_conditions
+ self.base_class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => ancestor_conditions
end
def path_ids
@@ -272,32 +281,32 @@ def path_conditions
end
def path depth_options = {}
- self.class.scope_depth(depth_options, depth).ordered_by_ancestry.scoped :conditions => path_conditions
+ 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.class.depth_cache_column, depth
+ write_attribute self.base_class.depth_cache_column, depth
end
# Parent
def parent= parent
- write_attribute(self.class.ancestry_column, if parent.blank? then nil else parent.child_ancestry end)
+ 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.class.find(parent_id) end
+ 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.class.find(parent_id) end
+ if parent_id.blank? then nil else self.base_class.find(parent_id) end
end
# Root
@@ -306,20 +315,20 @@ def root_id
end
def root
- if root_id == id then self else self.class.find(root_id) end
+ if root_id == id then self else self.base_class.find(root_id) end
end
def is_root?
- read_attribute(self.class.ancestry_column).blank?
+ read_attribute(self.base_class.ancestry_column).blank?
end
# Children
def child_conditions
- {self.class.ancestry_column => child_ancestry}
+ {self.base_class.ancestry_column => child_ancestry}
end
def children
- self.class.scoped :conditions => child_conditions
+ self.base_class.scoped :conditions => child_conditions
end
def child_ids
@@ -336,11 +345,11 @@ def is_childless?
# Siblings
def sibling_conditions
- {self.class.ancestry_column => read_attribute(self.class.ancestry_column)}
+ {self.base_class.ancestry_column => read_attribute(self.base_class.ancestry_column)}
end
def siblings
- self.class.scoped :conditions => sibling_conditions
+ self.base_class.scoped :conditions => sibling_conditions
end
def sibling_ids
@@ -357,11 +366,11 @@ def is_only_child?
# Descendants
def descendant_conditions
- ["#{self.class.ancestry_column} like ? or #{self.class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry]
+ ["#{self.base_class.ancestry_column} like ? or #{self.base_class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry]
end
def descendants depth_options = {}
- self.class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => descendant_conditions
+ self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => descendant_conditions
end
def descendant_ids depth_options = {}
@@ -370,11 +379,11 @@ def descendant_ids depth_options = {}
# Subtree
def subtree_conditions
- ["id = ? or #{self.class.ancestry_column} like ? or #{self.class.ancestry_column} = ?", self.id, "#{child_ancestry}/%", child_ancestry]
+ ["id = ? 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.class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => subtree_conditions
+ self.base_class.ordered_by_ancestry.scope_depth(depth_options, depth).scoped :conditions => subtree_conditions
end
def subtree_ids depth_options = {}
@@ -11,6 +11,12 @@ class AlternativeTestNode < ActiveRecord::Base
class ParentIdTestNode < ActiveRecord::Base
end
+class TestNodeSub1 < TestNode
+end
+
+class TestNodeSub2 < TestNode
+end
+
class ActsAsTreeTest < ActiveSupport::TestCase
load_schema
@@ -549,4 +555,17 @@ def test_exception_on_unknown_depth_column
TestNode.create!.subtree(:this_is_not_a_valid_depth_option => 42)
end
end
+
+ def test_sti_support
+ node1 = TestNodeSub1.create!
+ node2 = TestNodeSub2.create! :parent => node1
+ node3 = TestNodeSub1.create! :parent => node2
+ node4 = TestNodeSub2.create! :parent => node3
+ node5 = TestNodeSub1.create! :parent => node4
+
+ assert_equal [node2, node3, node4, node5], node1.descendants
+ assert_equal [node1, node2, node3, node4, node5], node1.subtree
+ assert_equal [node1, node2, node3, node4], node5.ancestors
+ assert_equal [node1, node2, node3, node4, node5], node5.path
+ end
end
View
@@ -6,9 +6,9 @@ sqlite3:
database: vendor/plugins/ancestry/test/ancestry_plugin.sqlite3.db
postgresql:
adapter: postgresql
- database: ancestry_performance
- username: ancestry_performance
- password: ancestry_performance
+ database: ancestry_development
+ username: ancestry
+ password: ancestry
mysql:
adapter: mysql
host: localhost
Oops, something went wrong.

0 comments on commit e3ea26a

Please sign in to comment.