activeshepherd 0.8.0
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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/Guardfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +138 -0
- data/Rakefile +24 -0
- data/activeshepherd.gemspec +34 -0
- data/lib/active_shepherd.rb +24 -0
- data/lib/active_shepherd/active_record_shim.rb +15 -0
- data/lib/active_shepherd/aggregate.rb +69 -0
- data/lib/active_shepherd/aggregate_root.rb +152 -0
- data/lib/active_shepherd/changes_validator.rb +11 -0
- data/lib/active_shepherd/class_validator.rb +11 -0
- data/lib/active_shepherd/deep_reverse_changes.rb +22 -0
- data/lib/active_shepherd/method.rb +81 -0
- data/lib/active_shepherd/methods/apply_changes.rb +53 -0
- data/lib/active_shepherd/methods/apply_state.rb +58 -0
- data/lib/active_shepherd/methods/query_changes.rb +53 -0
- data/lib/active_shepherd/methods/query_state.rb +38 -0
- data/lib/active_shepherd/traversal.rb +34 -0
- data/lib/active_shepherd/version.rb +3 -0
- data/lib/activeshepherd.rb +1 -0
- data/tags +123 -0
- data/test/integration/.gitkeep +0 -0
- data/test/integration/project_todo_scenario_test.rb +334 -0
- data/test/setup_test_models.rb +115 -0
- data/test/test_helper.rb +21 -0
- data/test/unit/.gitkeep +0 -0
- data/test/unit/aggregate_test.rb +4 -0
- data/test/unit/apply_changes_test.rb +17 -0
- data/test/unit/changes_validator_test.rb +19 -0
- data/test/unit/class_validator_test.rb +39 -0
- metadata +257 -0
File without changes
|
@@ -0,0 +1,334 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class IntegrationTest < MiniTest::Unit::TestCase
|
4
|
+
def setup
|
5
|
+
Project.destroy_all
|
6
|
+
|
7
|
+
@project = Project.new
|
8
|
+
|
9
|
+
@state = {
|
10
|
+
name: "Clean House",
|
11
|
+
owner_id: 1,
|
12
|
+
detail: {
|
13
|
+
description: "I need to clean the house"
|
14
|
+
},
|
15
|
+
todo_lists: [{
|
16
|
+
todos: [{
|
17
|
+
text: "Take out the trash",
|
18
|
+
todo_assignments: [{
|
19
|
+
assignee_id: 2,
|
20
|
+
},{
|
21
|
+
assignee_id: 3,
|
22
|
+
}],
|
23
|
+
comments: [{
|
24
|
+
author_id: 1,
|
25
|
+
text: "Have this done by Monday",
|
26
|
+
},{
|
27
|
+
author_id: 2,
|
28
|
+
text: "I'll do my best",
|
29
|
+
}],
|
30
|
+
},{
|
31
|
+
text: "Sweep the floor"
|
32
|
+
}],
|
33
|
+
}],
|
34
|
+
}
|
35
|
+
|
36
|
+
@changes = {
|
37
|
+
name: ["Clean House", "Clean My House"],
|
38
|
+
detail: {
|
39
|
+
description: ["I need to clean the house", "I need to clean my house"],
|
40
|
+
},
|
41
|
+
todo_lists: {
|
42
|
+
0 => {
|
43
|
+
todos: {
|
44
|
+
0 => { text: ["Take out the trash", "Take out my trash"] },
|
45
|
+
2 => { text: [nil, "Another task!"], _create: '1' },
|
46
|
+
},
|
47
|
+
},
|
48
|
+
},
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_state_setter_sets_attributes
|
53
|
+
@project.aggregate_state = { name: "Foo" }
|
54
|
+
|
55
|
+
assert_equal "Foo", @project.name
|
56
|
+
end
|
57
|
+
|
58
|
+
def test_state_getter_gets_attributes
|
59
|
+
@project.name = "Foo"
|
60
|
+
|
61
|
+
assert_equal "Foo", @project.aggregate_state[:name]
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_state_setter_sets_attributes_on_has_one_associated_object
|
65
|
+
@project.aggregate_state = { detail: { description: "Foobar" }}
|
66
|
+
|
67
|
+
assert_equal "Foobar", @project.detail.try(:description)
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_state_getter_gets_attributes_on_has_one_associated_object
|
71
|
+
@project.build_detail({ description: "Foobar" })
|
72
|
+
|
73
|
+
assert_equal "Foobar", @project.aggregate_state.fetch(:detail, {})[:description]
|
74
|
+
end
|
75
|
+
|
76
|
+
def test_state_getter_ignores_default_scope_attributes
|
77
|
+
@project.todo_lists.build({
|
78
|
+
todos_attributes: {
|
79
|
+
"0" => {
|
80
|
+
text: "Foo"
|
81
|
+
}
|
82
|
+
}
|
83
|
+
})
|
84
|
+
|
85
|
+
todo = @project.todo_lists.first.todos.first
|
86
|
+
c = todo.comments.build({ text: "Bar" })
|
87
|
+
|
88
|
+
# FIXME
|
89
|
+
comment_state = @project.aggregate_state[:todo_lists].first[:todos].first[:comments].first
|
90
|
+
# /fixme
|
91
|
+
assert_nil comment_state[:commentable_type]
|
92
|
+
end
|
93
|
+
|
94
|
+
def test_state_getter_ignores_has_many_through_associations
|
95
|
+
end
|
96
|
+
|
97
|
+
def test_state_setter_ignores_has_many_through_associations
|
98
|
+
end
|
99
|
+
|
100
|
+
def test_state_setter_sets_attributes_on_has_many_associated_object
|
101
|
+
@project.aggregate_state = { todo_lists: [{ todos: [{ text: "Hi" },{ text: "Bye" }] }] }
|
102
|
+
|
103
|
+
assert_equal 1, @project.todo_lists.size
|
104
|
+
assert_equal 2, @project.todo_lists.first.todos.size
|
105
|
+
assert_equal ["Hi", "Bye"], @project.todo_lists.first.todos.map(&:text)
|
106
|
+
end
|
107
|
+
|
108
|
+
def test_state_getter_rejects_id
|
109
|
+
refute @project.aggregate_state.keys.include?(:id)
|
110
|
+
end
|
111
|
+
|
112
|
+
def test_state_getter_rejects_unpopulated_associations
|
113
|
+
assert_equal 0, @project.todo_lists.size
|
114
|
+
assert_nil @project.detail
|
115
|
+
|
116
|
+
refute @project.aggregate_state.has_key?(:todo_lists)
|
117
|
+
refute @project.aggregate_state.has_key?(:detail)
|
118
|
+
end
|
119
|
+
|
120
|
+
def test_does_not_walk_associations_to_other_entities
|
121
|
+
@project.aggregate_state = { owner: { name: "Joe Schmoe" } }
|
122
|
+
|
123
|
+
refute_equal "Joe Schmoe", @project.owner.try(:name)
|
124
|
+
end
|
125
|
+
|
126
|
+
=begin
|
127
|
+
# FIXME: rails 4 is removing read only associations
|
128
|
+
def test_state_getter_does_not_walk_read_only_associations
|
129
|
+
@project.todo_lists.build.tap do |todo_list|
|
130
|
+
todo_list.todos.build({ text: "Hi" })
|
131
|
+
@project.recent_todo_list = todo_list
|
132
|
+
end
|
133
|
+
|
134
|
+
assert_nil @project.aggregate_state[:recent_todo_list]
|
135
|
+
end
|
136
|
+
|
137
|
+
def test_state_setter_does_not_walk_read_only_associations
|
138
|
+
@project.aggregate_state = { recent_todo_list: {} }
|
139
|
+
|
140
|
+
assert_nil @project.recent_todo_list
|
141
|
+
end
|
142
|
+
=end
|
143
|
+
|
144
|
+
def test_state_getter_ignores_foreign_key_relationship_to_parent_object
|
145
|
+
@project.save
|
146
|
+
@project.build_detail({ description: "Foo" })
|
147
|
+
|
148
|
+
assert_equal({ description: "Foo" }, @project.aggregate_state[:detail])
|
149
|
+
end
|
150
|
+
|
151
|
+
def test_changes_getter_ignores_foreign_key_relationship_to_parent_object
|
152
|
+
build_persisted_state
|
153
|
+
|
154
|
+
@project.todo_lists.build
|
155
|
+
|
156
|
+
assert_equal({todo_lists: { 1 => {_create: '1' }}}, @project.aggregate_changes)
|
157
|
+
end
|
158
|
+
|
159
|
+
def test_all_changes_to_associated_objects_show_up_in_aggregate_changes
|
160
|
+
build_persisted_state
|
161
|
+
|
162
|
+
@project.name = "Clean My House"
|
163
|
+
@project.detail.description = "I need to clean my house"
|
164
|
+
@project.todo_lists.first.todos.first.text = "Take out my trash"
|
165
|
+
@project.todo_lists.first.todos.build({ text: "Another task!" })
|
166
|
+
|
167
|
+
assert_equal @changes, @project.aggregate_changes
|
168
|
+
end
|
169
|
+
|
170
|
+
def test_applying_changes_shows_up_in_model_and_its_associations
|
171
|
+
build_persisted_state
|
172
|
+
|
173
|
+
@project.aggregate_state = @state
|
174
|
+
@project.save!
|
175
|
+
|
176
|
+
assert_equal "Clean House", @project.name
|
177
|
+
assert_equal "I need to clean the house", @project.detail.description
|
178
|
+
assert_equal "Take out the trash", @project.todo_lists.first.todos.first.text
|
179
|
+
assert_equal 2, @project.todo_lists.first.todos.size
|
180
|
+
|
181
|
+
@project.aggregate_changes = @changes
|
182
|
+
|
183
|
+
assert_equal "Clean My House", @project.name
|
184
|
+
assert_equal "I need to clean my house", @project.detail.description
|
185
|
+
assert_equal "Take out my trash", @project.todo_lists.first.todos.first.text
|
186
|
+
assert_equal 3, @project.todo_lists.first.todos.size
|
187
|
+
end
|
188
|
+
|
189
|
+
def test_applying_reverse_changes_invokes_apply_change_on_the_reverse_hash
|
190
|
+
build_persisted_state
|
191
|
+
|
192
|
+
@project.aggregate_changes = @changes
|
193
|
+
@project.save!
|
194
|
+
|
195
|
+
assert_equal "Clean My House", @project.name
|
196
|
+
assert_equal "I need to clean my house", @project.detail.description
|
197
|
+
assert_equal "Take out my trash", @project.todo_lists.first.todos.first.text
|
198
|
+
assert_equal 3, @project.todo_lists.first.todos.size
|
199
|
+
|
200
|
+
@project.reverse_aggregate_changes = @changes
|
201
|
+
@project.save!
|
202
|
+
|
203
|
+
assert_equal "Clean House", @project.name
|
204
|
+
assert_equal "I need to clean the house", @project.detail.description
|
205
|
+
assert_equal "Take out the trash", @project.todo_lists.first.todos.first.text
|
206
|
+
assert_equal 2, @project.todo_lists.first.todos.size
|
207
|
+
end
|
208
|
+
|
209
|
+
def test_state_getter_symbolizes_all_keys
|
210
|
+
@project.name = "Foo"
|
211
|
+
|
212
|
+
assert_equal({ name: "Foo" }, @project.aggregate_state)
|
213
|
+
end
|
214
|
+
|
215
|
+
def test_state_setter_populates_object_graph
|
216
|
+
@project.aggregate_state = @state
|
217
|
+
assert_equal @state, @project.aggregate_state
|
218
|
+
end
|
219
|
+
|
220
|
+
def test_state_setter_marks_existing_associations_for_deletion
|
221
|
+
@project.aggregate_state = @state
|
222
|
+
@project.save
|
223
|
+
|
224
|
+
assert_equal 2, @project.todo_lists.first.todos.size
|
225
|
+
|
226
|
+
new_state = Marshal.load(Marshal.dump(@state))
|
227
|
+
|
228
|
+
new_state[:todo_lists].first[:todos].first.tap do |todo|
|
229
|
+
todo[:todo_assignments].pop
|
230
|
+
todo[:comments].pop
|
231
|
+
todo[:comments].unshift({author_id: 2, text: "Brand new comment"})
|
232
|
+
end
|
233
|
+
new_state.delete(:detail)
|
234
|
+
|
235
|
+
@project.aggregate_state = new_state
|
236
|
+
@project.save
|
237
|
+
@project.reload
|
238
|
+
|
239
|
+
@project.todo_lists.first.todos.first.tap do |todo|
|
240
|
+
assert_equal 1, todo.todo_assignments.size
|
241
|
+
assert_equal 2, todo.comments.size
|
242
|
+
assert_equal ["Brand new comment", "Have this done by Monday"], todo.comments.map(&:text)
|
243
|
+
end
|
244
|
+
|
245
|
+
assert_nil @project.detail
|
246
|
+
end
|
247
|
+
|
248
|
+
def test_state_setter_resets_unsupplied_attributes_to_default
|
249
|
+
@project.aggregate_state = @state.merge(status: 5)
|
250
|
+
@project.save
|
251
|
+
|
252
|
+
new_state = Marshal.load(Marshal.dump(@state))
|
253
|
+
new_state.delete(:status)
|
254
|
+
|
255
|
+
@project.aggregate_state = new_state
|
256
|
+
|
257
|
+
assert_equal Project.new.status, @project.status
|
258
|
+
end
|
259
|
+
|
260
|
+
def state_setter_can_set_timestamps
|
261
|
+
timestamp = (1.year.ago - 15.seconds)
|
262
|
+
|
263
|
+
@project.state = { created_at: timestamp, updated_at: timestamp + 14.days }
|
264
|
+
assert_equal timestamp, @project.created_at
|
265
|
+
assert_equal timestamp + 14.days, @project.updated_at
|
266
|
+
end
|
267
|
+
|
268
|
+
def test_state_getter_respects_serialized_attributes
|
269
|
+
@project.fruit = :apple
|
270
|
+
assert_equal 'ELPPA', @project.aggregate_state[:fruit]
|
271
|
+
end
|
272
|
+
|
273
|
+
def test_state_setter_respects_serialized_attributes
|
274
|
+
@project.aggregate_state = @state
|
275
|
+
assert_equal nil, @project.fruit
|
276
|
+
|
277
|
+
@state[:fruit] = 'EGNARO'
|
278
|
+
@project.aggregate_state = @state
|
279
|
+
assert_equal :orange, @project.fruit
|
280
|
+
end
|
281
|
+
|
282
|
+
def test_state_changes_getter_and_setter_respect_serialized_attributes
|
283
|
+
build_persisted_state
|
284
|
+
|
285
|
+
@project.fruit = :banana
|
286
|
+
assert_equal [nil, 'ANANAB'], @project.aggregate_changes[:fruit]
|
287
|
+
@project.save!
|
288
|
+
assert_equal :banana, @project.reload.fruit
|
289
|
+
|
290
|
+
@project.fruit = :pear
|
291
|
+
assert_equal ['ANANAB', 'RAEP'], @project.aggregate_changes[:fruit]
|
292
|
+
|
293
|
+
@project.reload
|
294
|
+
assert_equal :banana, @project.fruit
|
295
|
+
@project.aggregate_changes = { fruit: [ 'ANANAB', 'OGNAM'] }
|
296
|
+
assert_equal :mango, @project.fruit
|
297
|
+
end
|
298
|
+
|
299
|
+
private
|
300
|
+
|
301
|
+
# Test 'changes' behavior with this common background
|
302
|
+
def build_persisted_state
|
303
|
+
@project.name = "Clean House"
|
304
|
+
@project.owner_id = 1
|
305
|
+
@project.todo_lists.build({
|
306
|
+
todos_attributes: {
|
307
|
+
"0" => { text: "Take out the trash" },
|
308
|
+
"1" => { text: "Make your bed" },
|
309
|
+
},
|
310
|
+
})
|
311
|
+
@project.build_detail({ description: "I need to clean the house" })
|
312
|
+
|
313
|
+
@project.save
|
314
|
+
|
315
|
+
assert_equal({}, @project.aggregate_changes)
|
316
|
+
end
|
317
|
+
|
318
|
+
def reverse_changes
|
319
|
+
@changes = {
|
320
|
+
name: ["Clean My House", "Clean House"],
|
321
|
+
detail: {
|
322
|
+
description: ["I need to clean my house", "I need to clean the house"],
|
323
|
+
},
|
324
|
+
todo_lists: {
|
325
|
+
0 => {
|
326
|
+
todos: {
|
327
|
+
0 => { text: ["Take out my trash", "Take out the trash"] },
|
328
|
+
2 => { _destroy: '1' },
|
329
|
+
},
|
330
|
+
},
|
331
|
+
},
|
332
|
+
}
|
333
|
+
end
|
334
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
ActiveRecord::Migration.create_table :users, force: true do |t|
|
2
|
+
t.string :name
|
3
|
+
end
|
4
|
+
|
5
|
+
ActiveRecord::Migration.create_table :comments, force: true do |t|
|
6
|
+
t.string :text
|
7
|
+
t.string :type
|
8
|
+
t.belongs_to :commentable, polymorphic: true
|
9
|
+
t.belongs_to :author
|
10
|
+
end
|
11
|
+
|
12
|
+
ActiveRecord::Migration.create_table :projects, force: true do |t|
|
13
|
+
t.string :name
|
14
|
+
t.belongs_to :owner
|
15
|
+
t.integer :status, default: 1, null: false
|
16
|
+
t.string :fruit
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
ActiveRecord::Migration.create_table :project_details, force: true do |t|
|
21
|
+
t.belongs_to :project
|
22
|
+
t.text :description
|
23
|
+
end
|
24
|
+
|
25
|
+
ActiveRecord::Migration.create_table :project_todo_lists, force: true do |t|
|
26
|
+
t.belongs_to :project
|
27
|
+
t.timestamps
|
28
|
+
end
|
29
|
+
|
30
|
+
ActiveRecord::Migration.create_table :project_todos, force: true do |t|
|
31
|
+
t.string :text
|
32
|
+
t.integer :comments_count, default: 0, null: false
|
33
|
+
t.belongs_to :todo_list
|
34
|
+
end
|
35
|
+
|
36
|
+
ActiveRecord::Migration.create_table :project_todo_assignments, force: true do |t|
|
37
|
+
t.belongs_to :todo
|
38
|
+
t.belongs_to :assignee
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
User = Class.new(ActiveRecord::Base)
|
43
|
+
|
44
|
+
class Comment < ActiveRecord::Base
|
45
|
+
belongs_to :commentable, polymorphic: true, counter_cache: true
|
46
|
+
belongs_to :author, class_name: "User"
|
47
|
+
end
|
48
|
+
|
49
|
+
class Project < ActiveRecord::Base
|
50
|
+
act_as_aggregate_root!
|
51
|
+
|
52
|
+
belongs_to :owner, class_name: "User"
|
53
|
+
|
54
|
+
has_one :detail, inverse_of: :project, dependent: :destroy, autosave: true
|
55
|
+
|
56
|
+
has_many :todo_lists, validate: true, dependent: :destroy, inverse_of: :project, autosave: true
|
57
|
+
has_many :todos, through: :todo_lists # not part of aggregate
|
58
|
+
|
59
|
+
has_one :recent_todo_list, ->{ order("updated_at DESC") },
|
60
|
+
class_name: "Project::TodoList"
|
61
|
+
|
62
|
+
FruitSerializer = Class.new do
|
63
|
+
def self.dump(fruit)
|
64
|
+
return nil if fruit.nil?
|
65
|
+
fruit.to_s.reverse.upcase
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.load(blob)
|
69
|
+
return nil if blob.nil?
|
70
|
+
blob.reverse.downcase.to_sym
|
71
|
+
end
|
72
|
+
end
|
73
|
+
serialize :fruit, FruitSerializer
|
74
|
+
end
|
75
|
+
|
76
|
+
class Project::Detail < ActiveRecord::Base
|
77
|
+
belongs_to :project, inverse_of: :detail
|
78
|
+
end
|
79
|
+
|
80
|
+
class Project::TodoList < ActiveRecord::Base
|
81
|
+
belongs_to :project, inverse_of: :todo_lists
|
82
|
+
has_many :todos, validate: true, dependent: :destroy, inverse_of: :todo_list
|
83
|
+
|
84
|
+
accepts_nested_attributes_for :todos
|
85
|
+
end
|
86
|
+
|
87
|
+
class Project::Todo < ActiveRecord::Base
|
88
|
+
belongs_to :todo_list, inverse_of: :todos
|
89
|
+
has_many :todo_assignments, inverse_of: :todo, dependent: :destroy, autosave: true
|
90
|
+
|
91
|
+
has_many :assignees, through: :todo_assignments
|
92
|
+
has_many :comments, inverse_of: :todo, foreign_key: "commentable_id"
|
93
|
+
|
94
|
+
validates :text, uniqueness: { scope: :todo_list_id }
|
95
|
+
end
|
96
|
+
|
97
|
+
class Project::TodoAssignment < ActiveRecord::Base
|
98
|
+
belongs_to :todo, inverse_of: :todo_assignments
|
99
|
+
belongs_to :assignee, class_name: "User"
|
100
|
+
end
|
101
|
+
|
102
|
+
class Project::Comment < Comment
|
103
|
+
belongs_to :todo, foreign_key: "commentable_id", inverse_of: :comments
|
104
|
+
|
105
|
+
default_scope { where({ commentable_type: "Project::Todo" }) }
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
# Instantiate an instance of each model class; this causes mistakes in the above
|
110
|
+
# code to fail fast.
|
111
|
+
models = ObjectSpace.each_object(Class).select do |klass|
|
112
|
+
klass < ActiveRecord::Base
|
113
|
+
end
|
114
|
+
|
115
|
+
models.each(&:new)
|