chronic_tree 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +87 -0
- data/LICENSE +21 -0
- data/README.md +278 -0
- data/Rakefile +6 -0
- data/chronic_tree.gemspec +30 -0
- data/lib/chronic_tree.rb +73 -0
- data/lib/chronic_tree/active_record/element.rb +26 -0
- data/lib/chronic_tree/active_record/relation.rb +34 -0
- data/lib/chronic_tree/command.rb +165 -0
- data/lib/chronic_tree/operation.rb +212 -0
- data/lib/chronic_tree/railtie.rb +9 -0
- data/lib/chronic_tree/travesal.rb +64 -0
- data/lib/chronic_tree/version.rb +3 -0
- data/lib/generators/chronic_tree/install_generator.rb +15 -0
- data/lib/generators/chronic_tree/templates/create_chronic_tree_elements.rb +22 -0
- data/spec/chronic_tree_spec.rb +223 -0
- data/spec/spec_helper.rb +438 -0
- metadata +179 -0
data/lib/chronic_tree.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
require "chronic_tree/version"
|
2
|
+
require "chronic_tree/active_record/element"
|
3
|
+
require "chronic_tree/travesal"
|
4
|
+
require "chronic_tree/operation"
|
5
|
+
require "chronic_tree/active_record/relation"
|
6
|
+
require "set"
|
7
|
+
|
8
|
+
module ChronicTree
|
9
|
+
require 'chronic_tree/railtie' if defined?(Rails)
|
10
|
+
|
11
|
+
class Error < RuntimeError; end
|
12
|
+
class InvalidObjectError < Error; end
|
13
|
+
|
14
|
+
include ChronicTree::ActiveRecord::Relation
|
15
|
+
include ChronicTree::Travesal
|
16
|
+
include ChronicTree::Operation
|
17
|
+
|
18
|
+
def self.included(base)
|
19
|
+
base.class_eval <<-RUBY
|
20
|
+
attr_reader :current_time_at, :current_scope_name
|
21
|
+
|
22
|
+
@@defined_chronic_tree_scopes = Set.new
|
23
|
+
|
24
|
+
def self.defined_chronic_tree_scopes
|
25
|
+
@@defined_chronic_tree_scopes
|
26
|
+
end
|
27
|
+
RUBY
|
28
|
+
|
29
|
+
base.extend(ClassMethods)
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
|
34
|
+
def chronic_tree(scope_name = 'default')
|
35
|
+
self.class_eval <<-RUBY
|
36
|
+
has_many :"elements_under_#{scope_name}_parent",
|
37
|
+
proc { |owner| where(scope_name: scope_name, tree_type: owner.class.name) },
|
38
|
+
class_name: 'ChronicTree::ActiveRecord::Element',
|
39
|
+
foreign_key: 'parent_id',
|
40
|
+
dependent: :destroy
|
41
|
+
|
42
|
+
has_many :"elements_as_#{scope_name}_child",
|
43
|
+
proc { |owner| where(scope_name: scope_name, tree_type: owner.class.name) },
|
44
|
+
class_name: 'ChronicTree::ActiveRecord::Element',
|
45
|
+
foreign_key: 'child_id',
|
46
|
+
dependent: :destroy
|
47
|
+
|
48
|
+
has_many :"elements_under_#{scope_name}_root",
|
49
|
+
proc { |owner| where(scope_name: scope_name, tree_type: owner.class.name) },
|
50
|
+
class_name: 'ChronicTree::ActiveRecord::Element',
|
51
|
+
foreign_key: 'root_id',
|
52
|
+
dependent: :destroy
|
53
|
+
RUBY
|
54
|
+
|
55
|
+
defined_chronic_tree_scopes << scope_name
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
def as_tree(time_at = Time.now, scope_name = 'default')
|
61
|
+
time_at, scope_name = Time.now, time_at if time_at.is_a?(String)
|
62
|
+
|
63
|
+
raise "Scope name is wrong. It should be equal to the name that has " \
|
64
|
+
"been set up." unless self.class.defined_chronic_tree_scopes.include?(scope_name)
|
65
|
+
|
66
|
+
raise "Time can't be later than now." if time_at > Time.now
|
67
|
+
|
68
|
+
@current_time_at = time_at
|
69
|
+
@current_scope_name = scope_name
|
70
|
+
|
71
|
+
self
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module ChronicTree
|
2
|
+
module ActiveRecord
|
3
|
+
class Element < ::ActiveRecord::Base
|
4
|
+
self.table_name = "chronic_tree_elements"
|
5
|
+
|
6
|
+
belongs_to :parent, polymorphic: true, foreign_type: 'tree_type'
|
7
|
+
belongs_to :child, polymorphic: true, foreign_type: 'tree_type'
|
8
|
+
belongs_to :root, polymorphic: true, foreign_type: 'tree_type'
|
9
|
+
|
10
|
+
scope :at, -> (time = Time.now) {
|
11
|
+
start_time_col = self.arel_table[:start_time]
|
12
|
+
end_time_col = self.arel_table[:end_time]
|
13
|
+
where(start_time_col.lteq(time)).where(end_time_col.gt(time))
|
14
|
+
}
|
15
|
+
|
16
|
+
scope :exclude_root, -> {
|
17
|
+
root_id_col = self.arel_table[:root_id]
|
18
|
+
child_id_col = self.arel_table[:child_id]
|
19
|
+
where(root_id_col.not_eq(child_id_col))
|
20
|
+
}
|
21
|
+
|
22
|
+
scope :direct, -> { where(distance: 1) }
|
23
|
+
scope :all_distance, -> { unscope(where: :distance) }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module ChronicTree
|
2
|
+
module ActiveRecord
|
3
|
+
module Relation
|
4
|
+
def children_relation(time_at, scope_name)
|
5
|
+
send("elements_under_#{scope_name}_parent").
|
6
|
+
at(time_at).
|
7
|
+
direct.
|
8
|
+
exclude_root.
|
9
|
+
includes(:child)
|
10
|
+
end
|
11
|
+
|
12
|
+
def parent_relation(time_at, scope_name)
|
13
|
+
existed_relation(time_at, scope_name).
|
14
|
+
direct.
|
15
|
+
exclude_root
|
16
|
+
end
|
17
|
+
|
18
|
+
def existed_relation(time_at, scope_name)
|
19
|
+
send("elements_as_#{scope_name}_child").
|
20
|
+
at(time_at)
|
21
|
+
end
|
22
|
+
|
23
|
+
def descendants_relation(time_at, scope_name)
|
24
|
+
children_relation(time_at, scope_name).order(:distance).all_distance
|
25
|
+
end
|
26
|
+
|
27
|
+
def ancestors_relation(time_at, scope_name)
|
28
|
+
existed_relation(time_at, scope_name).
|
29
|
+
includes(:parent).
|
30
|
+
order(:distance)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
module ChronicTree
|
2
|
+
module Command
|
3
|
+
|
4
|
+
class AddRootElement
|
5
|
+
attr_reader :source
|
6
|
+
|
7
|
+
def initialize(source)
|
8
|
+
@source = source
|
9
|
+
end
|
10
|
+
|
11
|
+
def do
|
12
|
+
source.send("elements_under_#{source.current_scope_name}_root").create(
|
13
|
+
child: source,
|
14
|
+
parent: source,
|
15
|
+
distance: 0,
|
16
|
+
start_time: source.current_time_at,
|
17
|
+
end_time: 1000.years.since(source.current_time_at)
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class AddChildElement
|
23
|
+
attr_reader :source, :child_id, :distance, :source_root_id
|
24
|
+
|
25
|
+
def initialize(source, child_object_or_child_id, distance = 1, source_root_id = nil)
|
26
|
+
@source = source
|
27
|
+
@source_root_id = if source_root_id.nil?
|
28
|
+
source.root.id
|
29
|
+
else
|
30
|
+
source_root_id
|
31
|
+
end
|
32
|
+
|
33
|
+
@distance = distance
|
34
|
+
|
35
|
+
@child_id = if child_object_or_child_id.respond_to?(:id)
|
36
|
+
child_object_or_child_id.send(:id)
|
37
|
+
else
|
38
|
+
child_object_or_child_id
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def do
|
43
|
+
source.send("elements_under_#{source.current_scope_name}_parent").create(
|
44
|
+
root_id: source_root_id,
|
45
|
+
child_id: child_id,
|
46
|
+
distance: distance,
|
47
|
+
start_time: source.current_time_at,
|
48
|
+
end_time: 1000.years.since(source.current_time_at)
|
49
|
+
)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class RemoveChildElementsFromAncestors
|
54
|
+
attr_reader :ancestor_ids, :child_ids, :source
|
55
|
+
|
56
|
+
def initialize(source, ancestor_ids, child_ids)
|
57
|
+
@source = source
|
58
|
+
@ancestor_ids = ancestor_ids
|
59
|
+
@child_ids = child_ids
|
60
|
+
end
|
61
|
+
|
62
|
+
def do
|
63
|
+
ChronicTree::ActiveRecord::Element.at(source.current_time_at).
|
64
|
+
where(tree_type: source.class.name).
|
65
|
+
where(scope_name: source.current_scope_name).
|
66
|
+
where(parent_id: ancestor_ids).
|
67
|
+
where(child_id: child_ids).
|
68
|
+
update_all(end_time: source.current_time_at)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
class AddChildElementsToNewAncestors
|
73
|
+
attr_reader :source, :source_root_id, :new_ancestors, :child_elements
|
74
|
+
|
75
|
+
def initialize(source, source_root_id, new_ancestors, child_elements)
|
76
|
+
@source = source
|
77
|
+
@source_root_id = source_root_id
|
78
|
+
@new_ancestors = new_ancestors
|
79
|
+
@child_elements = child_elements
|
80
|
+
end
|
81
|
+
|
82
|
+
def do
|
83
|
+
new_ancestors.each_with_index do |parent_object, i|
|
84
|
+
child_elements.each do |e|
|
85
|
+
AddChildElement.new(parent_object, e.child_id, e.distance + i, source_root_id).do
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class AddChildElementToOrigAncestors
|
92
|
+
attr_reader :source, :child
|
93
|
+
|
94
|
+
def initialize(source, child)
|
95
|
+
@source = source
|
96
|
+
@child = child
|
97
|
+
end
|
98
|
+
|
99
|
+
def do
|
100
|
+
source.ancestors.each_with_index do |parent_object, index|
|
101
|
+
AddChildElement.new(parent_object, child, index + 2, source.root.id).do
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class AddReplacedObjToOrigAncestors
|
107
|
+
attr_reader :source, :source_root_id, :source_ancestors, :target
|
108
|
+
|
109
|
+
def initialize(source, source_root_id, source_ancestors, target)
|
110
|
+
@source = source
|
111
|
+
@source_root_id = source_root_id
|
112
|
+
@source_ancestors = source_ancestors
|
113
|
+
@target = target
|
114
|
+
end
|
115
|
+
|
116
|
+
def do
|
117
|
+
source_ancestors.each_with_index do |parent_object, i|
|
118
|
+
AddChildElement.new(parent_object, target.id, i + 1, source_root_id).do
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
class AddChildElementsToReplacedObject
|
124
|
+
attr_reader :source, :source_root_id, :target, :child_elements
|
125
|
+
|
126
|
+
def initialize(source, source_root_id, target, child_elements)
|
127
|
+
@source = source
|
128
|
+
@source_root_id = source_root_id
|
129
|
+
@target = target
|
130
|
+
@child_elements = child_elements
|
131
|
+
end
|
132
|
+
|
133
|
+
def do
|
134
|
+
child_elements.each { |el| AddChildElement.new(target, el.child_id, el.distance).do }
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class RemoveSelfElement
|
139
|
+
attr_reader :source
|
140
|
+
|
141
|
+
def initialize(source)
|
142
|
+
@source = source
|
143
|
+
end
|
144
|
+
|
145
|
+
def do
|
146
|
+
source.existed_relation(source.current_time_at, source.current_scope_name).each do |el|
|
147
|
+
el.update_attribute(:end_time, source.current_time_at)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
class RemoveDescendantElements
|
153
|
+
attr_reader :source
|
154
|
+
|
155
|
+
def initialize(source)
|
156
|
+
@source = source
|
157
|
+
end
|
158
|
+
|
159
|
+
def do
|
160
|
+
source.flat_descendants.each { |object| RemoveSelfElement.new(object).do }
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'chronic_tree/command'
|
2
|
+
|
3
|
+
module ChronicTree
|
4
|
+
module Operation
|
5
|
+
|
6
|
+
class ChangeParent
|
7
|
+
attr_reader :source, :source_root_id, :ready_to_move_elements, :new_ancestors
|
8
|
+
|
9
|
+
def initialize(source, new_parent_object)
|
10
|
+
@source = source
|
11
|
+
|
12
|
+
@ready_to_move_elements = source.
|
13
|
+
descendants_relation(source.current_time_at,
|
14
|
+
source.current_scope_name).map do |el|
|
15
|
+
|
16
|
+
OpenStruct.new(child_id: el.child_id, distance: el.distance + 1)
|
17
|
+
end
|
18
|
+
@ready_to_move_elements << OpenStruct.new(child_id: source.id, distance: 1)
|
19
|
+
|
20
|
+
@source_root_id = source.root.id
|
21
|
+
|
22
|
+
@new_ancestors = new_parent_object.ancestors
|
23
|
+
@new_ancestors.unshift(new_parent_object) unless new_parent_object.id == @source_root_id
|
24
|
+
end
|
25
|
+
|
26
|
+
def act
|
27
|
+
::ActiveRecord::Base.transaction do
|
28
|
+
ChronicTree::Command::RemoveChildElementsFromAncestors.new(
|
29
|
+
source,
|
30
|
+
source.ancestors.map(&:id),
|
31
|
+
ready_to_move_elements.map(&:child_id)).do
|
32
|
+
|
33
|
+
ChronicTree::Command::AddChildElementsToNewAncestors.new(
|
34
|
+
source,
|
35
|
+
source_root_id,
|
36
|
+
new_ancestors,
|
37
|
+
ready_to_move_elements).do
|
38
|
+
end
|
39
|
+
|
40
|
+
source
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class ReplaceBy
|
45
|
+
attr_reader :source, :target, :source_root_id, :source_ancestors, :ready_to_move_elements
|
46
|
+
|
47
|
+
def initialize(source, target)
|
48
|
+
@source = source
|
49
|
+
@target = target
|
50
|
+
|
51
|
+
@ready_to_move_elements = source.descendants_relation(
|
52
|
+
source.current_time_at,
|
53
|
+
source.current_scope_name).map do |el|
|
54
|
+
|
55
|
+
OpenStruct.new(id: el.id, child_id: el.child_id, distance: el.distance)
|
56
|
+
end
|
57
|
+
|
58
|
+
@source_root_id = (source == source.root) ? target.id : source.root.id
|
59
|
+
@source_ancestors = source.ancestors
|
60
|
+
end
|
61
|
+
|
62
|
+
def act
|
63
|
+
::ActiveRecord::Base.transaction do
|
64
|
+
remove_obsolete_elements
|
65
|
+
add_new_elements
|
66
|
+
end
|
67
|
+
|
68
|
+
source
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def remove_obsolete_elements
|
74
|
+
ChronicTree::Command::RemoveChildElementsFromAncestors.new(
|
75
|
+
source,
|
76
|
+
[source.id],
|
77
|
+
ready_to_move_elements.map(&:child_id)).do
|
78
|
+
|
79
|
+
ChronicTree::Command::RemoveSelfElement.new(source).do
|
80
|
+
end
|
81
|
+
|
82
|
+
def add_new_elements
|
83
|
+
if source.id == source_root_id
|
84
|
+
ChronicTree::Command::AddRootElement.new(source).do
|
85
|
+
else
|
86
|
+
ChronicTree::Command::AddReplacedObjToOrigAncestors.new(
|
87
|
+
source, source_root_id, source_ancestors, target).do
|
88
|
+
end
|
89
|
+
|
90
|
+
ChronicTree::Command::AddChildElementsToReplacedObject.new(
|
91
|
+
source, source_root_id, target, ready_to_move_elements).do
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
|
97
|
+
def add_as_root
|
98
|
+
as_tree(Time.now, current_scope_name || 'default')
|
99
|
+
|
100
|
+
raise_if_tree_is_not_empty
|
101
|
+
|
102
|
+
ChronicTree::Command::AddRootElement.new(self).do
|
103
|
+
|
104
|
+
self
|
105
|
+
end
|
106
|
+
|
107
|
+
def add_child(object)
|
108
|
+
as_tree(Time.now, current_scope_name || 'default') && object.as_tree(current_time_at, current_scope_name)
|
109
|
+
|
110
|
+
raise_if_object_unmatched(object)
|
111
|
+
raise_if_object_is_in_the_tree(object)
|
112
|
+
raise_if_self_is_not_in_the_tree
|
113
|
+
|
114
|
+
::ActiveRecord::Base.transaction do
|
115
|
+
ChronicTree::Command::AddChildElement.new(self, object).do
|
116
|
+
ChronicTree::Command::AddChildElementToOrigAncestors.new(self, object).do unless self == root
|
117
|
+
end
|
118
|
+
|
119
|
+
self
|
120
|
+
end
|
121
|
+
|
122
|
+
def remove_self
|
123
|
+
as_tree(Time.now, current_scope_name || 'default')
|
124
|
+
|
125
|
+
raise_if_self_is_not_in_the_tree
|
126
|
+
|
127
|
+
::ActiveRecord::Base.transaction do
|
128
|
+
ChronicTree::Command::RemoveSelfElement.new(self).do
|
129
|
+
ChronicTree::Command::RemoveDescendantElements.new(self).do
|
130
|
+
end
|
131
|
+
|
132
|
+
self
|
133
|
+
end
|
134
|
+
|
135
|
+
def remove_descendants
|
136
|
+
as_tree(Time.now, current_scope_name || 'default')
|
137
|
+
|
138
|
+
raise_if_self_is_not_in_the_tree
|
139
|
+
|
140
|
+
::ActiveRecord::Base.transaction { ChronicTree::Command::RemoveDescendantElements.new(self).do }
|
141
|
+
|
142
|
+
self
|
143
|
+
end
|
144
|
+
|
145
|
+
def change_parent(object)
|
146
|
+
as_tree(Time.now, current_scope_name || 'default') && object.as_tree(current_time_at, current_scope_name)
|
147
|
+
return self if self != root && parent == object
|
148
|
+
|
149
|
+
raise_if_object_unmatched(object)
|
150
|
+
raise_if_object_is_not_in_the_tree(object)
|
151
|
+
raise_if_object_equals_to_self(object)
|
152
|
+
raise_if_object_is_a_child_of_self(object)
|
153
|
+
raise_if_self_is_not_in_the_tree
|
154
|
+
|
155
|
+
ChangeParent.new(self, object).act
|
156
|
+
end
|
157
|
+
|
158
|
+
def replace_by(object)
|
159
|
+
as_tree(Time.now, current_scope_name || 'default') && object.as_tree(current_time_at, current_scope_name)
|
160
|
+
|
161
|
+
raise_if_object_unmatched(object)
|
162
|
+
raise_if_object_is_in_the_tree(object)
|
163
|
+
raise_if_self_is_not_in_the_tree
|
164
|
+
|
165
|
+
ReplaceBy.new(self, object).act
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def raise_if_object_unmatched(object)
|
171
|
+
if object.class.name != self.class.name
|
172
|
+
raise InvalidObjectError, "Object invalid. You can't add two types of objects in a tree."
|
173
|
+
end
|
174
|
+
|
175
|
+
raise InvalidObjectError, "Object invalid. You must save it first." if object.new_record?
|
176
|
+
end
|
177
|
+
|
178
|
+
def raise_if_object_is_in_the_tree(object)
|
179
|
+
if object.existed_in_tree?(current_time_at, current_scope_name)
|
180
|
+
raise InvalidObjectError, "Object must not be in the tree now."
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def raise_if_object_is_not_in_the_tree(object)
|
185
|
+
unless object.existed_in_tree?(current_time_at, current_scope_name)
|
186
|
+
raise InvalidObjectError, "Object must be in the tree now."
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def raise_if_object_equals_to_self(object)
|
191
|
+
raise InvalidObjectError, "Object can't be equal to self." if self == object
|
192
|
+
end
|
193
|
+
|
194
|
+
def raise_if_object_is_a_child_of_self(object)
|
195
|
+
if descendants_relation(current_time_at, current_scope_name).where(child_id: object.id).any?
|
196
|
+
raise InvalidObjectError, "Object can't be a child of self."
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def raise_if_self_is_not_in_the_tree
|
201
|
+
unless existed_in_tree?(current_time_at, current_scope_name)
|
202
|
+
raise Error, "Self must be in the tree now."
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def raise_if_tree_is_not_empty
|
207
|
+
unless tree_empty?(current_time_at, current_scope_name)
|
208
|
+
raise Error, "Tree isn't empty, can't add root element."
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|