acts_as_ordered_tree 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/README.md +73 -0
- data/Rakefile +1 -0
- data/acts_as_ordered_tree.gemspec +29 -0
- data/init.rb +2 -0
- data/lib/acts_as_ordered_tree/iterator.rb +36 -0
- data/lib/acts_as_ordered_tree/list.rb +100 -0
- data/lib/acts_as_ordered_tree/tree.rb +156 -0
- data/lib/acts_as_ordered_tree/version.rb +3 -0
- data/lib/acts_as_ordered_tree.rb +52 -0
- data/spec/acts_as_ordered_tree_spec.rb +314 -0
- data/spec/database.yml +3 -0
- data/spec/iterator_spec.rb +73 -0
- data/spec/test_helper.rb +53 -0
- metadata +149 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# Acts As Ordered Tree
|
2
|
+
WARNING! THIS GEM IS NOT COMPATIBLE WITH <a href="http://ordered-tree.rubyforge.org">ordered_tree gem</a>.
|
3
|
+
|
4
|
+
Specify this `acts_as` extension if you want to model an ordered tree structure by providing a parent association, a children
|
5
|
+
association and a sort column. For proper use you should have a foreign key column, which by default is called `parent_id`, and
|
6
|
+
a sort column, which by default is called `position`.
|
7
|
+
|
8
|
+
## Requirements
|
9
|
+
Gem depends on `active_record >= 3`.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
Install it via rubygems:
|
13
|
+
|
14
|
+
```bash
|
15
|
+
gem install acts_as_ordered_tree
|
16
|
+
```
|
17
|
+
|
18
|
+
Gem depends on `acts_as_tree` and `acts_as_list` gems.
|
19
|
+
|
20
|
+
Setup your model:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
class Node < ActiveRecord::Base
|
24
|
+
acts_as_ordered_tree
|
25
|
+
|
26
|
+
# gem introduces new ActiveRecord callbacks:
|
27
|
+
# *_reorder - fires when position (but not parent node) is changed
|
28
|
+
# *_move - fires when parent node is changed
|
29
|
+
before_reorder :do_smth
|
30
|
+
before_move :do_smth_else
|
31
|
+
end
|
32
|
+
```
|
33
|
+
|
34
|
+
## Example
|
35
|
+
```ruby
|
36
|
+
# root
|
37
|
+
# \_ child1
|
38
|
+
# \_ subchild1
|
39
|
+
# \_ subchild2
|
40
|
+
|
41
|
+
|
42
|
+
root = Node.create(:name => "root")
|
43
|
+
child1 = root.children.create(:name => "child1")
|
44
|
+
subchild1 = child1.children.create("name" => "subchild1")
|
45
|
+
subchild2 = child1.children.create("name" => "subchild2")
|
46
|
+
|
47
|
+
Node.roots # => [root]
|
48
|
+
|
49
|
+
root.root? # => true
|
50
|
+
root.parent # => nil
|
51
|
+
root.ancestors # => []
|
52
|
+
root.descendants # => [child1, subchild1, subchild2]
|
53
|
+
|
54
|
+
child1.parent # => root
|
55
|
+
child1.ancestors # => [root]
|
56
|
+
child1.children # => [subchild1, subchild2]
|
57
|
+
child1.descendants # => [subchild1, subchild2]
|
58
|
+
child1.root? # => false
|
59
|
+
child1.leaf? # => false
|
60
|
+
|
61
|
+
subchild1.ancestors # => [child1, root]
|
62
|
+
subchild1.root # => [root]
|
63
|
+
subchild1.leaf? # => true
|
64
|
+
subchild1.first? # => true
|
65
|
+
subchild1.last? # => false
|
66
|
+
subchild2.last? # => true
|
67
|
+
|
68
|
+
subchild1.move_to_above_of(child1)
|
69
|
+
subchild1.move_to_bottom_of(child1)
|
70
|
+
subchild1.move_to_child_of(root)
|
71
|
+
subchild1.move_lower
|
72
|
+
subchild1.move_higher
|
73
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "acts_as_ordered_tree/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "acts_as_ordered_tree"
|
7
|
+
s.version = ActsAsOrderedTree::VERSION
|
8
|
+
s.authors = ["Alexei Mikhailov"]
|
9
|
+
s.email = ["amikhailov83@gmail.com"]
|
10
|
+
s.homepage = "https://github.com/take-five/acts_as_ordered_tree"
|
11
|
+
s.summary = %q{ActiveRecord extension for sorted adjacency lists support}
|
12
|
+
|
13
|
+
s.rubyforge_project = "acts_as_ordered_tree"
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
|
20
|
+
s.add_dependency "activesupport", "~> 3"
|
21
|
+
s.add_dependency "activerecord", "~> 3"
|
22
|
+
s.add_dependency "acts_as_tree", "~> 0.1"
|
23
|
+
s.add_dependency "acts_as_list", "~> 0.1"
|
24
|
+
|
25
|
+
s.add_development_dependency "rspec"
|
26
|
+
s.add_development_dependency "simplecov"
|
27
|
+
s.add_development_dependency "sqlite3"
|
28
|
+
s.add_development_dependency "bundler"
|
29
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require "enumerator"
|
2
|
+
|
3
|
+
module ActsAsOrderedTree
|
4
|
+
# Enhanced enumerator
|
5
|
+
#
|
6
|
+
# Allows to use array specific methods like +empty?+, +reverse?+ and so on
|
7
|
+
class Iterator < Enumerator
|
8
|
+
class NullArgument < ArgumentError; end
|
9
|
+
NA = NullArgument.new
|
10
|
+
|
11
|
+
def initialize(*args, &block)
|
12
|
+
@enumerator = Enumerator.new(*args, &block)
|
13
|
+
|
14
|
+
super() do |yielder|
|
15
|
+
@enumerator.each do |e|
|
16
|
+
yielder << e
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Delegate everything to underlying array
|
22
|
+
def method_missing(method_id, *args, &block)
|
23
|
+
if method_id !~ /^(__|instance_eval|class|object_id)/
|
24
|
+
to_ary!.__send__(method_id, *args, &block)
|
25
|
+
else
|
26
|
+
super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
def to_ary!
|
32
|
+
@enumerator = @enumerator.to_a unless @enumerator.is_a?(Array)
|
33
|
+
@enumerator
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "active_support/concern"
|
2
|
+
require "active_support/core_ext/object/with_options"
|
3
|
+
|
4
|
+
module ActsAsOrderedTree
|
5
|
+
module List
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
include PatchedMethods
|
10
|
+
scope :ordered, order(position_column)
|
11
|
+
|
12
|
+
with_options :if => :parent_changed? do |opts|
|
13
|
+
opts.before_update :remove_from_old_list
|
14
|
+
opts.before_update :add_to_list_bottom
|
15
|
+
end
|
16
|
+
|
17
|
+
define_model_callbacks :reorder
|
18
|
+
around_update :__around_reorder, :if => :position_changed?,
|
19
|
+
:unless => :parent_changed?
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns true if record has changes in +parent_id+
|
23
|
+
def position_changed?
|
24
|
+
changes.has_key?(position_column.to_s)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
# Turn off reorder callbacks temporary
|
29
|
+
def skip_reorder_callbacks(skip = true) #:nodoc:
|
30
|
+
@skip_reorder_callbacks = skip
|
31
|
+
result = yield
|
32
|
+
@skip_reorder_callbacks = false
|
33
|
+
|
34
|
+
result
|
35
|
+
end
|
36
|
+
|
37
|
+
def __around_reorder #:nodoc:
|
38
|
+
if @skip_reorder_callbacks
|
39
|
+
yield
|
40
|
+
else
|
41
|
+
run_callbacks(:reorder) { yield }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# It should invoke callbacks, so we patch +acts_as_list+ methods
|
46
|
+
module PatchedMethods #:nodoc:all
|
47
|
+
private
|
48
|
+
def remove_from_old_list
|
49
|
+
unchanged = self.class.find(id)
|
50
|
+
unchanged.send(:decrement_positions_on_lower_items)
|
51
|
+
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
# This has the effect of moving all the higher items up one.
|
56
|
+
def decrement_positions_on_higher_items(position)
|
57
|
+
higher_than(position).each do |node|
|
58
|
+
node.decrement!(position_column)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# This has the effect of moving all the lower items up one.
|
63
|
+
def decrement_positions_on_lower_items
|
64
|
+
return unless in_list?
|
65
|
+
lower_than(position).each do |node|
|
66
|
+
node.decrement!(position_column)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# This has the effect of moving all the higher items down one.
|
71
|
+
def increment_positions_on_higher_items
|
72
|
+
return unless in_list?
|
73
|
+
|
74
|
+
higher_than(self[position_column]).each do |node|
|
75
|
+
node.increment!(position_column)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def increment_positions_on_all_items
|
80
|
+
self_and_siblings.each do |sib|
|
81
|
+
sib.increment!(position_column)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def increment_positions_on_lower_items(position)
|
86
|
+
lower_than(position).each do |node|
|
87
|
+
node.increment!(position_column)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def lower_than(position)
|
92
|
+
acts_as_list_class.where(scope_condition).where("#{position_column} >= ?", position.to_i)
|
93
|
+
end
|
94
|
+
|
95
|
+
def higher_than(position)
|
96
|
+
acts_as_list_class.where(scope_condition).where("#{position_column} < ?", position.to_i)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end # module List
|
100
|
+
end # module ActsAsOrderedTree
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require "active_support/concern"
|
2
|
+
|
3
|
+
module ActsAsOrderedTree
|
4
|
+
module Tree
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
# remove +acts_as_tree+ version of +roots+ method
|
9
|
+
class << self
|
10
|
+
remove_method :roots
|
11
|
+
|
12
|
+
# Retrieve first root node
|
13
|
+
#
|
14
|
+
# Replacement for native +ActsAsTree.root+ method
|
15
|
+
def root
|
16
|
+
roots.first
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
scope :roots, where(parent_column => nil).order(quoted_position_column)
|
21
|
+
|
22
|
+
validate :validate_incest
|
23
|
+
|
24
|
+
define_model_callbacks :move
|
25
|
+
around_update :__around_move, :if => :parent_changed?
|
26
|
+
end
|
27
|
+
|
28
|
+
# == Instance methods
|
29
|
+
|
30
|
+
# returns a Enumerator of ancestors, starting from parent until root
|
31
|
+
def ancestors
|
32
|
+
Iterator.new do |yielder|
|
33
|
+
node = self
|
34
|
+
yielder << node while node = node.parent
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# returns a Enumerator of ancestors, including self
|
39
|
+
def self_and_ancestors
|
40
|
+
Iterator.new do |y|
|
41
|
+
y << self
|
42
|
+
ancestors.each { |a| y << a }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# returns a Enumerator of node's descendants, traversing depth first
|
47
|
+
#
|
48
|
+
# == Example
|
49
|
+
# The tree:
|
50
|
+
# # * root
|
51
|
+
# # * child_1
|
52
|
+
# # * grandchild_1_1
|
53
|
+
# # * grandchild_1_2
|
54
|
+
# # * child_2
|
55
|
+
# # * grandchild_2_1
|
56
|
+
#
|
57
|
+
# root.descendants # => [root,
|
58
|
+
# # child_1, grandchild_1_1, grandchild_1_2,
|
59
|
+
# # child_2, grandchild_2_1]
|
60
|
+
def descendants
|
61
|
+
Iterator.new do |yielder|
|
62
|
+
children.each do |child|
|
63
|
+
yielder << child
|
64
|
+
|
65
|
+
child.descendants.each do |grandchild|
|
66
|
+
yielder << grandchild
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end # def descendants
|
71
|
+
|
72
|
+
def self_and_descendants
|
73
|
+
Iterator.new do |y|
|
74
|
+
y << self
|
75
|
+
descendants.each { |x| y << x }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Returns depth of current node
|
80
|
+
def depth
|
81
|
+
ancestors.count
|
82
|
+
end
|
83
|
+
alias level depth
|
84
|
+
|
85
|
+
# Return +true+ if +self+ is root node
|
86
|
+
def root?
|
87
|
+
self[parent_column].nil?
|
88
|
+
end
|
89
|
+
|
90
|
+
# Return +true+ if +self+ is leaf node
|
91
|
+
def leaf?
|
92
|
+
children.empty?
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns true if record has changes in +parent_id+
|
96
|
+
def parent_changed?
|
97
|
+
changes.has_key?(parent_column.to_s)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Move node to other parent, make it last child of new parent
|
101
|
+
def move_to_child_of(another_parent)
|
102
|
+
transaction do
|
103
|
+
self.parent = another_parent
|
104
|
+
|
105
|
+
p_changed = parent_changed?
|
106
|
+
save if p_changed
|
107
|
+
|
108
|
+
skip_reorder_callbacks(p_changed) { move_to_bottom }
|
109
|
+
|
110
|
+
parent.children.reload
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Move node to position of +another_node+, shift down lower items
|
115
|
+
def move_to_above_of(another_node)
|
116
|
+
p_changed = parent != another_node.parent
|
117
|
+
|
118
|
+
transaction do
|
119
|
+
move_to_child_of(another_node.parent)
|
120
|
+
|
121
|
+
skip_reorder_callbacks(p_changed) do
|
122
|
+
insert_at(another_node[position_column])
|
123
|
+
end
|
124
|
+
|
125
|
+
another_node.parent.children.reload if another_node.parent.present?
|
126
|
+
another_node.reload
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Move node to the next of +another_node+, shift down lower items
|
131
|
+
def move_to_bottom_of(another_node)
|
132
|
+
p_changed = parent != another_node.parent
|
133
|
+
|
134
|
+
transaction do
|
135
|
+
move_to_child_of(another_node.parent)
|
136
|
+
|
137
|
+
skip_reorder_callbacks(p_changed) do
|
138
|
+
insert_at(another_node[position_column] + 1)
|
139
|
+
end
|
140
|
+
|
141
|
+
another_node.parent.children.reload if another_node.parent.present?
|
142
|
+
another_node.reload
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
protected
|
147
|
+
def validate_incest #:nodoc:
|
148
|
+
errors.add(:parent, :linked_to_self) if parent == self
|
149
|
+
errors.add(:parent, :linked_to_descendant) if descendants.include?(parent)
|
150
|
+
end
|
151
|
+
|
152
|
+
def __around_move #:nodoc:
|
153
|
+
run_callbacks(:move) { yield }
|
154
|
+
end
|
155
|
+
end # module Tree
|
156
|
+
end # module ActsAsOrderedTree
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "enumerator"
|
2
|
+
|
3
|
+
require "active_record"
|
4
|
+
require "acts_as_list"
|
5
|
+
require "acts_as_tree"
|
6
|
+
|
7
|
+
require "acts_as_ordered_tree/version"
|
8
|
+
require "acts_as_ordered_tree/iterator"
|
9
|
+
require "acts_as_ordered_tree/tree"
|
10
|
+
require "acts_as_ordered_tree/list"
|
11
|
+
|
12
|
+
module ActsAsOrderedTree
|
13
|
+
def acts_as_ordered_tree(options = {})
|
14
|
+
configuration = configure_ordered_tree(options)
|
15
|
+
|
16
|
+
acts_as_tree :foreign_key => parent_column,
|
17
|
+
:order => position_column,
|
18
|
+
:counter_cache => configuration[:counter_cache]
|
19
|
+
|
20
|
+
acts_as_list :column => position_column,
|
21
|
+
:scope => parent_column
|
22
|
+
|
23
|
+
# acts_as_tree creates ugly associations
|
24
|
+
# patch them
|
25
|
+
children = reflect_on_association :children
|
26
|
+
children.options[:order] = quoted_position_column
|
27
|
+
|
28
|
+
include ActsAsOrderedTree::Tree
|
29
|
+
include ActsAsOrderedTree::List
|
30
|
+
end # def acts_as_ordered_tree
|
31
|
+
|
32
|
+
private
|
33
|
+
# Add ordered_tree configuration readers
|
34
|
+
def configure_ordered_tree(options = {}) #:nodoc:
|
35
|
+
configuration = { :foreign_key => :parent_id ,
|
36
|
+
:order => :position }
|
37
|
+
configuration.update(options) if options.is_a?(Hash)
|
38
|
+
|
39
|
+
class_attribute :parent_column, :position_column
|
40
|
+
|
41
|
+
self.parent_column = configuration[:foreign_key].to_sym
|
42
|
+
self.position_column = configuration[:order].to_sym
|
43
|
+
|
44
|
+
configuration
|
45
|
+
end # def configure_ordered_tree
|
46
|
+
|
47
|
+
def quoted_position_column #:nodoc:
|
48
|
+
[quoted_table_name, connection.quote_column_name(position_column)].join('.')
|
49
|
+
end # def quoted_position_column
|
50
|
+
end # module ActsAsOrderedTree
|
51
|
+
|
52
|
+
ActiveRecord::Base.extend(ActsAsOrderedTree)
|
@@ -0,0 +1,314 @@
|
|
1
|
+
require File.expand_path('../test_helper', __FILE__)
|
2
|
+
|
3
|
+
describe ActsAsOrderedTree do
|
4
|
+
before :all do
|
5
|
+
root = Node.create(:name => "Root")
|
6
|
+
child1 = Node.create(:parent_id => root.id, :name => "Child 1")
|
7
|
+
child2 = Node.create(:parent_id => root.id, :name => "Child 2")
|
8
|
+
|
9
|
+
Node.create(:parent_id => child1.id, :name => "Subchild 1")
|
10
|
+
Node.create(:parent_id => child1.id, :name => "Subchild 2")
|
11
|
+
Node.create(:parent_id => child2.id, :name => "Subchild 3")
|
12
|
+
end
|
13
|
+
|
14
|
+
let(:root) { Node.where(:parent_id => nil).first }
|
15
|
+
let(:branch) { Node.where(:parent_id => root.id).first }
|
16
|
+
let(:second_branch) { Node.where(:parent_id => root.id).last }
|
17
|
+
let(:leaf) { Node.where(:parent_id => branch.id).first }
|
18
|
+
let(:last) { Node.last }
|
19
|
+
let(:blank) { Node.new(:parent_id => branch.id) }
|
20
|
+
|
21
|
+
describe "class" do
|
22
|
+
it "should be properly configured" do
|
23
|
+
Node.position_column.should eq(:position)
|
24
|
+
Node.parent_column.should eq(:parent_id)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should have roots" do
|
28
|
+
Node.roots.count.should eq(1)
|
29
|
+
Node.roots.first.should eq(root)
|
30
|
+
Node.root.should eq(root)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "tree" do
|
35
|
+
it "should have roots" do
|
36
|
+
root.root.should eq(root)
|
37
|
+
branch.root.should eq(root)
|
38
|
+
leaf.root.should eq(root)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should have children" do
|
42
|
+
root.children.count.should eq(2)
|
43
|
+
branch.children.count.should eq(2)
|
44
|
+
leaf.children.count.should eq(0)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should have parents" do
|
48
|
+
root.parent.should be(nil)
|
49
|
+
branch.parent.should eq(root)
|
50
|
+
leaf.parent.should eq(branch)
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should return true if root" do
|
54
|
+
root.root?.should be(true)
|
55
|
+
branch.root?.should be(false)
|
56
|
+
leaf.root?.should be(false)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should return true if leaf" do
|
60
|
+
root.leaf?.should be(false)
|
61
|
+
branch.leaf?.should be(false)
|
62
|
+
leaf.leaf?.should be(true)
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should tell about node's depth" do
|
66
|
+
root.depth.should eq(0)
|
67
|
+
branch.depth.should eq(1)
|
68
|
+
leaf.depth.should eq(2)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "should iterate over ancestors" do
|
72
|
+
leaf.self_and_ancestors.should have(3).items
|
73
|
+
leaf.ancestors.should have(2).items
|
74
|
+
branch.ancestors.should have(1).items
|
75
|
+
root.ancestors.should have(0).items
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should iterate over descendants" do
|
79
|
+
root.self_and_descendants.should have(6).items
|
80
|
+
|
81
|
+
root.descendants.should have(5).items
|
82
|
+
root.descendants.first.should eq(branch)
|
83
|
+
root.descendants.last.should eq(last)
|
84
|
+
|
85
|
+
branch.descendants.should have(2).items
|
86
|
+
branch.descendants.first.should eq(leaf)
|
87
|
+
|
88
|
+
leaf.descendants.should have(0).items
|
89
|
+
end
|
90
|
+
|
91
|
+
it "should have siblings" do
|
92
|
+
branch.self_and_siblings.should have(2).items
|
93
|
+
branch.self_and_siblings.should include(branch)
|
94
|
+
|
95
|
+
branch.siblings.should have(1).item
|
96
|
+
branch.siblings.should_not include(branch)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe "list" do
|
101
|
+
it "should be ordered" do
|
102
|
+
root.position.should eq(1)
|
103
|
+
root.children.first.position.should eq(1)
|
104
|
+
root.children.last.position.should eq(2)
|
105
|
+
end
|
106
|
+
|
107
|
+
it "should be sortable through scope" do
|
108
|
+
Node.where(:parent_id => root.id).ordered.first.should eq(branch)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe "mutations" do
|
113
|
+
around(:each) do |example|
|
114
|
+
Node.transaction do
|
115
|
+
example.run
|
116
|
+
|
117
|
+
raise ActiveRecord::Rollback
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
it "should be placed to the bottom of the list" do
|
122
|
+
blank.save
|
123
|
+
branch.children.last.should eq(blank)
|
124
|
+
end
|
125
|
+
|
126
|
+
it "should be placed to the middle of the list" do
|
127
|
+
blank.position = 2
|
128
|
+
blank.save
|
129
|
+
|
130
|
+
blank.position.should eq(2)
|
131
|
+
blank.siblings.should have(2).items
|
132
|
+
blank.siblings.last.position.should eq(3)
|
133
|
+
end
|
134
|
+
|
135
|
+
it "should be movable inside parent" do
|
136
|
+
last_child = branch.children.last
|
137
|
+
|
138
|
+
blank.save
|
139
|
+
blank.move_higher
|
140
|
+
|
141
|
+
blank.position.should eq(2)
|
142
|
+
last_child.reload.position.should eq(3)
|
143
|
+
|
144
|
+
blank.move_lower
|
145
|
+
blank.position.should eq(3)
|
146
|
+
end
|
147
|
+
|
148
|
+
it "should be movable to bottom of its parent" do
|
149
|
+
first_child = branch.children.first
|
150
|
+
|
151
|
+
first_child.move_to_bottom
|
152
|
+
first_child.position.should eq(2)
|
153
|
+
first_child.reload.position.should eq(2)
|
154
|
+
end
|
155
|
+
|
156
|
+
it "should be movable to top of its parent" do
|
157
|
+
first_child = branch.children.first
|
158
|
+
last_child = branch.children.last
|
159
|
+
|
160
|
+
last_child.move_to_top
|
161
|
+
|
162
|
+
last_child.position.should eq(1)
|
163
|
+
last_child.reload.position.should eq(1)
|
164
|
+
|
165
|
+
first_child.reload.position.should eq(2)
|
166
|
+
end
|
167
|
+
|
168
|
+
it "should shift up lower items when parent is changed" do
|
169
|
+
first_child = branch.children.first
|
170
|
+
last_child = branch.children.last
|
171
|
+
|
172
|
+
# move to other parent
|
173
|
+
first_child.parent = second_branch
|
174
|
+
first_child.should be_parent_changed
|
175
|
+
|
176
|
+
first_child.save
|
177
|
+
|
178
|
+
# old sibling should shift up
|
179
|
+
last_child.reload.position.should eq(1)
|
180
|
+
end
|
181
|
+
|
182
|
+
it "should save its previous position when parent is changed" do
|
183
|
+
first_child = branch.children.first
|
184
|
+
|
185
|
+
first_child.parent = second_branch
|
186
|
+
first_child.save
|
187
|
+
|
188
|
+
first_child.position.should eq(1)
|
189
|
+
last.position.should eq(2)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "should be movable to last position of new parent" do
|
193
|
+
first_child = branch.children.first
|
194
|
+
|
195
|
+
first_child.move_to_child_of(second_branch)
|
196
|
+
first_child.parent.should eq(second_branch)
|
197
|
+
first_child.should be_last
|
198
|
+
end
|
199
|
+
|
200
|
+
it "should be movable to above of some node" do
|
201
|
+
first_child = branch.children.first
|
202
|
+
above_of = second_branch.children.first
|
203
|
+
|
204
|
+
first_child.move_to_above_of(above_of)
|
205
|
+
first_child.parent.should eq(second_branch)
|
206
|
+
|
207
|
+
first_child.position.should eq(1)
|
208
|
+
above_of.position.should eq(2)
|
209
|
+
end
|
210
|
+
|
211
|
+
it "should be movable to bottom of some node" do
|
212
|
+
second = second_branch
|
213
|
+
|
214
|
+
first_child = branch.children.first
|
215
|
+
|
216
|
+
first_child.move_to_bottom_of(branch)
|
217
|
+
first_child.parent.should eq(branch.parent)
|
218
|
+
|
219
|
+
first_child.position.should eq(2)
|
220
|
+
second.reload.position.should eq(3)
|
221
|
+
end
|
222
|
+
|
223
|
+
it "should shift up lower items on destroy" do
|
224
|
+
branch.children.first.destroy
|
225
|
+
|
226
|
+
branch.children.should have(1).items
|
227
|
+
branch.children.first.position.should eq(1)
|
228
|
+
end
|
229
|
+
|
230
|
+
describe "callbacks" do
|
231
|
+
it "should fire *_reorder callbacks when position (but not parent) changes" do
|
232
|
+
examples_count = 6
|
233
|
+
|
234
|
+
second_branch.should_receive(:on_before_reorder).exactly(examples_count)
|
235
|
+
second_branch.should_receive(:on_around_reorder).exactly(examples_count)
|
236
|
+
second_branch.should_receive(:on_after_reorder).exactly(examples_count)
|
237
|
+
|
238
|
+
second_branch.move_higher
|
239
|
+
second_branch.move_lower
|
240
|
+
second_branch.move_to_top
|
241
|
+
second_branch.move_to_bottom
|
242
|
+
second_branch.decrement_position
|
243
|
+
second_branch.increment_position
|
244
|
+
end
|
245
|
+
|
246
|
+
it "should not fire *_reorder callbacks when parent_changes" do
|
247
|
+
leaf.should_not_receive(:on_before_reorder)
|
248
|
+
leaf.should_not_receive(:on_around_reorder)
|
249
|
+
leaf.should_not_receive(:on_after_reorder)
|
250
|
+
|
251
|
+
p1 = leaf.parent
|
252
|
+
p2 = second_branch
|
253
|
+
|
254
|
+
leaf.move_to_child_of(p2)
|
255
|
+
leaf.move_to_above_of(p1.children.first)
|
256
|
+
leaf.move_to_child_of(p2)
|
257
|
+
leaf.move_to_bottom_of(p1.children.first)
|
258
|
+
end
|
259
|
+
|
260
|
+
it "should not fire *_reorder callbacks when position is not changed" do
|
261
|
+
leaf.should_not_receive(:on_before_reorder)
|
262
|
+
leaf.should_not_receive(:on_around_reorder)
|
263
|
+
leaf.should_not_receive(:on_after_reorder)
|
264
|
+
|
265
|
+
last.should_not_receive(:on_before_reorder)
|
266
|
+
last.should_not_receive(:on_around_reorder)
|
267
|
+
last.should_not_receive(:on_after_reorder)
|
268
|
+
|
269
|
+
leaf.move_higher
|
270
|
+
last.move_lower
|
271
|
+
|
272
|
+
leaf.save
|
273
|
+
last.save
|
274
|
+
end
|
275
|
+
|
276
|
+
it "should fire *_move callbacks when parent is changed" do
|
277
|
+
examples_count = 3
|
278
|
+
leaf.should_receive(:on_before_move).exactly(examples_count)
|
279
|
+
leaf.should_receive(:on_after_move).exactly(examples_count)
|
280
|
+
leaf.should_receive(:on_around_move).exactly(examples_count)
|
281
|
+
|
282
|
+
p1 = leaf.parent
|
283
|
+
p2 = second_branch
|
284
|
+
|
285
|
+
leaf.move_to_child_of(p2)
|
286
|
+
leaf.move_to_above_of(p1)
|
287
|
+
leaf.move_to_bottom_of(p1.children.first)
|
288
|
+
end
|
289
|
+
|
290
|
+
it "should not fire *_move callbacks when parent is not changed" do
|
291
|
+
leaf.should_not_receive(:on_before_move)
|
292
|
+
leaf.should_not_receive(:on_after_move)
|
293
|
+
leaf.should_not_receive(:on_around_move)
|
294
|
+
|
295
|
+
leaf.move_to_child_of(leaf.parent)
|
296
|
+
leaf.move_to_above_of(leaf.siblings.first)
|
297
|
+
leaf.move_to_bottom_of(leaf.siblings.first)
|
298
|
+
leaf.reload.save
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
describe "validations" do
|
304
|
+
it "should not allow to link parent to itself" do
|
305
|
+
branch.parent = branch
|
306
|
+
branch.should_not be_valid
|
307
|
+
end
|
308
|
+
|
309
|
+
it "should not allow to link to one of its descendants" do
|
310
|
+
branch.parent = leaf
|
311
|
+
branch.should_not be_valid
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
data/spec/database.yml
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
require File.expand_path('../test_helper', __FILE__)
|
2
|
+
|
3
|
+
describe ActsAsOrderedTree::Iterator do
|
4
|
+
let(:iterator) do
|
5
|
+
ActsAsOrderedTree::Iterator.new([1, 2, 3, 4, 2, 3])
|
6
|
+
end
|
7
|
+
|
8
|
+
let(:blanks) { ActsAsOrderedTree::Iterator.new([1, nil, 3]) }
|
9
|
+
|
10
|
+
it "should have random access" do
|
11
|
+
iterator[1].should eq(2)
|
12
|
+
iterator.at(1).should eq(2)
|
13
|
+
iterator.fetch(1).should eq(2)
|
14
|
+
iterator.values_at(1, 2).should eq([2, 3])
|
15
|
+
iterator.last.should eq(3)
|
16
|
+
iterator.slice(1, 2).should have(2).items
|
17
|
+
iterator.sample.should be_a(Fixnum)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should support operators" do
|
21
|
+
(iterator + [5]).should have(7).items
|
22
|
+
(iterator - [4]).should have(5).items
|
23
|
+
(iterator * 2).should have(12).items
|
24
|
+
(iterator & [4]).should have(1).items
|
25
|
+
(iterator | [4]).should have(4).items
|
26
|
+
iterator.concat([5]).should have(7).items
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should find left index" do
|
30
|
+
iterator.find_index(2).should eq(1)
|
31
|
+
iterator.find_index { |n| n == 2 }.should eq(1)
|
32
|
+
end
|
33
|
+
|
34
|
+
it "should find right index" do
|
35
|
+
iterator.rindex(2).should eq(4)
|
36
|
+
iterator.rindex { |n| n == 2 }.should eq(4)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should be compacted" do
|
40
|
+
blanks.compact.should have(2).items
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should be mutable" do
|
44
|
+
iter = ActsAsOrderedTree::Iterator.new([1, 2])
|
45
|
+
iter << 3 # [1, 2, 3]
|
46
|
+
|
47
|
+
iter.should have(3).items
|
48
|
+
|
49
|
+
iter.insert(1, 99) # [1, 99, 2, 3]
|
50
|
+
iter.at(1).should eq(99)
|
51
|
+
|
52
|
+
last = iter.pop # [1, 99, 2]
|
53
|
+
iter.last.should eq(2)
|
54
|
+
last.should eq(3)
|
55
|
+
|
56
|
+
first = iter.shift # [99, 2]
|
57
|
+
iter.first.should eq(99)
|
58
|
+
first.should eq(1)
|
59
|
+
|
60
|
+
iter.unshift(100) # [100, 99, 2]
|
61
|
+
iter.first.should eq(100)
|
62
|
+
|
63
|
+
iter.push(4)
|
64
|
+
iter.should have(4).items
|
65
|
+
iter.last.should eq(4)
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should raise NoMethodError" do
|
69
|
+
iter = ActsAsOrderedTree::Iterator.new([1, 2])
|
70
|
+
|
71
|
+
lambda { iter.__undefined_method__ }.should raise_error(NoMethodError)
|
72
|
+
end
|
73
|
+
end
|
data/spec/test_helper.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require File.expand_path('../../init', __FILE__)
|
2
|
+
|
3
|
+
require "rspec"
|
4
|
+
require "rspec-expectations"
|
5
|
+
|
6
|
+
require "simplecov"
|
7
|
+
SimpleCov.start
|
8
|
+
|
9
|
+
require "acts_as_ordered_tree"
|
10
|
+
require "logger"
|
11
|
+
|
12
|
+
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
|
13
|
+
ActiveRecord::Base.establish_connection(config['database'])
|
14
|
+
ActiveRecord::Base.logger = Logger.new(ENV['DEBUG'] ? $stderr : '/dev/null')
|
15
|
+
|
16
|
+
# Create schema
|
17
|
+
ActiveRecord::Base.connection.create_table :nodes do |t|
|
18
|
+
t.integer :parent_id
|
19
|
+
t.integer :position
|
20
|
+
t.string :name
|
21
|
+
end
|
22
|
+
|
23
|
+
class Node < ActiveRecord::Base
|
24
|
+
acts_as_ordered_tree
|
25
|
+
|
26
|
+
before_reorder :on_before_reorder
|
27
|
+
after_reorder :on_after_reorder
|
28
|
+
around_reorder :on_around_reorder
|
29
|
+
before_move :on_before_move
|
30
|
+
after_move :on_after_move
|
31
|
+
around_move :on_around_move
|
32
|
+
|
33
|
+
def self.debug
|
34
|
+
buf = StringIO.new("", "w")
|
35
|
+
|
36
|
+
roots.each do |n|
|
37
|
+
buf.puts "! #{n.name}"
|
38
|
+
n.descendants.each do |d|
|
39
|
+
buf.puts "#{' ' * d.level * 2} (##{d.id}): #{d.name} @ #{d.position}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
print buf.string
|
44
|
+
end
|
45
|
+
|
46
|
+
# stub
|
47
|
+
def on_before_reorder;end
|
48
|
+
def on_after_reorder;end
|
49
|
+
def on_around_reorder;yield end
|
50
|
+
def on_before_move; end
|
51
|
+
def on_after_move; end
|
52
|
+
def on_around_move; yield end
|
53
|
+
end
|
metadata
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: acts_as_ordered_tree
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.7
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Alexei Mikhailov
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-02-15 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activesupport
|
16
|
+
requirement: &19980600 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '3'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *19980600
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: activerecord
|
27
|
+
requirement: &19980040 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ~>
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '3'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *19980040
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: acts_as_tree
|
38
|
+
requirement: &19979360 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ~>
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0.1'
|
44
|
+
type: :runtime
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *19979360
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: acts_as_list
|
49
|
+
requirement: &19978620 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.1'
|
55
|
+
type: :runtime
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *19978620
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: rspec
|
60
|
+
requirement: &19977860 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :development
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *19977860
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: simplecov
|
71
|
+
requirement: &19977100 !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: *19977100
|
80
|
+
- !ruby/object:Gem::Dependency
|
81
|
+
name: sqlite3
|
82
|
+
requirement: &19976120 !ruby/object:Gem::Requirement
|
83
|
+
none: false
|
84
|
+
requirements:
|
85
|
+
- - ! '>='
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0'
|
88
|
+
type: :development
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: *19976120
|
91
|
+
- !ruby/object:Gem::Dependency
|
92
|
+
name: bundler
|
93
|
+
requirement: &19945820 !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ! '>='
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
type: :development
|
100
|
+
prerelease: false
|
101
|
+
version_requirements: *19945820
|
102
|
+
description:
|
103
|
+
email:
|
104
|
+
- amikhailov83@gmail.com
|
105
|
+
executables: []
|
106
|
+
extensions: []
|
107
|
+
extra_rdoc_files: []
|
108
|
+
files:
|
109
|
+
- .gitignore
|
110
|
+
- Gemfile
|
111
|
+
- README.md
|
112
|
+
- Rakefile
|
113
|
+
- acts_as_ordered_tree.gemspec
|
114
|
+
- init.rb
|
115
|
+
- lib/acts_as_ordered_tree.rb
|
116
|
+
- lib/acts_as_ordered_tree/iterator.rb
|
117
|
+
- lib/acts_as_ordered_tree/list.rb
|
118
|
+
- lib/acts_as_ordered_tree/tree.rb
|
119
|
+
- lib/acts_as_ordered_tree/version.rb
|
120
|
+
- spec/acts_as_ordered_tree_spec.rb
|
121
|
+
- spec/database.yml
|
122
|
+
- spec/iterator_spec.rb
|
123
|
+
- spec/test_helper.rb
|
124
|
+
homepage: https://github.com/take-five/acts_as_ordered_tree
|
125
|
+
licenses: []
|
126
|
+
post_install_message:
|
127
|
+
rdoc_options: []
|
128
|
+
require_paths:
|
129
|
+
- lib
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
131
|
+
none: false
|
132
|
+
requirements:
|
133
|
+
- - ! '>='
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '0'
|
136
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project: acts_as_ordered_tree
|
144
|
+
rubygems_version: 1.8.10
|
145
|
+
signing_key:
|
146
|
+
specification_version: 3
|
147
|
+
summary: ActiveRecord extension for sorted adjacency lists support
|
148
|
+
test_files: []
|
149
|
+
has_rdoc:
|