memento 0.3.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.
@@ -0,0 +1,74 @@
1
+ module Memento::ActiveRecordMethods
2
+
3
+ IGNORE_ATTRIBUTES = [:updated_at, :created_at]
4
+
5
+ def self.included(base)
6
+ base.send :extend, ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+
11
+ def memento_changes(*action_types)
12
+ include InstanceMethods
13
+
14
+ self.memento_options = action_types.last.is_a?(Hash) ? action_types.pop : {}
15
+
16
+ action_types = Memento::Action::Base.action_types if action_types.empty?
17
+ action_types.map!(&:to_s).uniq!
18
+ unless (invalid = action_types - Memento::Action::Base.action_types).empty?
19
+ raise ArgumentError.new("Invalid memento_changes: #{invalid.to_sentence}; allowed are only #{Memento::Action::Base.action_types.to_sentence}")
20
+ end
21
+
22
+ action_types.each do |action_type|
23
+ callback_exists = send(:"after_#{action_type}_callback_chain").any? do |callback|
24
+ callback.method.to_sym == :"record_#{action_type}"
25
+ end
26
+ send :"after_#{action_type}", :"record_#{action_type}" unless callback_exists
27
+ end
28
+
29
+ has_many :memento_states, :class_name => "Memento::State", :as => :record
30
+ end
31
+
32
+ def memento_options
33
+ read_inheritable_attribute(:memento_options) || write_inheritable_attribute(:memento_options,{})
34
+ end
35
+
36
+ def memento_options=(options)
37
+ options.symbolize_keys!
38
+ options[:ignore] = [options[:ignore]].flatten.map(&:to_sym) if options[:ignore]
39
+ write_inheritable_attribute(:memento_options, memento_options.merge(options))
40
+ end
41
+ end
42
+
43
+ module InstanceMethods
44
+
45
+ def attributes_for_memento
46
+ filter_attributes_for_memento(attributes)
47
+ end
48
+
49
+ def changes_for_memento
50
+ filter_attributes_for_memento(changes)
51
+ end
52
+
53
+ private
54
+
55
+ def filter_attributes_for_memento(hash)
56
+ hash.delete_if do |key, value|
57
+ ignore_attributes_for_memento.include?(key.to_sym)
58
+ end
59
+ end
60
+
61
+ def ignore_attributes_for_memento
62
+ Memento::ActiveRecordMethods::IGNORE_ATTRIBUTES + (self.class.memento_options[:ignore] || [])
63
+ end
64
+
65
+ Memento::Action::Base.action_types.each do |action_type|
66
+ define_method :"record_#{action_type}" do
67
+ Memento.instance.add_state(action_type, self)
68
+ end
69
+ end
70
+ end
71
+
72
+ end
73
+
74
+ ActiveRecord::Base.send(:include, Memento::ActiveRecordMethods) if defined?(ActiveRecord::Base)
@@ -0,0 +1,36 @@
1
+ class Memento::ResultArray < Array
2
+
3
+ def errors
4
+ self.find_all{ |result| result.failed? }
5
+ end
6
+
7
+ def failed?
8
+ self.any?{ |result| result.failed? }
9
+ end
10
+
11
+ def success?
12
+ !failed?
13
+ end
14
+
15
+ end
16
+
17
+ class Memento::Result
18
+
19
+ attr_reader :object, :state
20
+
21
+ def initialize(object, state)
22
+ @object, @state = object, state
23
+ end
24
+
25
+ def error
26
+ @object.errors[:memento_undo]
27
+ end
28
+
29
+ def failed?
30
+ !!error
31
+ end
32
+
33
+ def success?
34
+ !failed?
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ class Memento::Session < ActiveRecord::Base
2
+ set_table_name "memento_sessions"
3
+
4
+ has_many :states, :class_name => "Memento::State", :dependent => :delete_all
5
+ belongs_to :user
6
+ validates_presence_of :user
7
+
8
+ def add_state(action_type, record)
9
+ states.store(action_type, record)
10
+ end
11
+
12
+ def undo
13
+ states.map(&:undo).inject(Memento::ResultArray.new) do |results, result|
14
+ result.state.destroy if result.success?
15
+ results << result
16
+ end
17
+ ensure
18
+ destroy if states.count.zero?
19
+ end
20
+
21
+ def undo!
22
+ transaction do
23
+ returning(undo) do |results|
24
+ raise Memento::ErrorOnRewind if results.failed?
25
+ end
26
+ end
27
+ end
28
+
29
+ end
@@ -0,0 +1,47 @@
1
+ class Memento::State < ActiveRecord::Base
2
+ set_table_name "memento_states"
3
+
4
+ belongs_to :session, :class_name => "Memento::Session"
5
+ belongs_to :record, :polymorphic => true
6
+
7
+ validates_presence_of :session
8
+ validates_presence_of :record
9
+ validates_presence_of :action_type
10
+ validates_inclusion_of :action_type, :in => Memento::Action::Base.action_types, :allow_blank => true
11
+
12
+ before_create :set_record_data
13
+
14
+ def self.store(action_type, record)
15
+ self.new(:action_type => action_type.to_s, :record => record) do |state|
16
+ state.save if state.fetch?
17
+ end
18
+ end
19
+
20
+ def undo
21
+ Memento::Result.new(action.undo, self)
22
+ end
23
+
24
+ def record_data
25
+ @record_data ||= Memento.serializer.load(read_attribute(:record_data))
26
+ end
27
+
28
+ def record_data=(data)
29
+ @record_data = nil
30
+ write_attribute(:record_data, Memento.serializer.dump(data))
31
+ end
32
+
33
+ def fetch?
34
+ action.fetch?
35
+ end
36
+
37
+ private
38
+
39
+ def set_record_data
40
+ self.record_data = action.fetch
41
+ end
42
+
43
+ def action
44
+ "memento/action/#{action_type}".classify.constantize.new(self)
45
+ end
46
+
47
+ end
data/memento.gemspec ADDED
@@ -0,0 +1,79 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{memento}
8
+ s.version = "0.3.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Yolk Sebastian Munz & Julia Soergel GbR"]
12
+ s.date = %q{2010-01-26}
13
+ s.email = %q{sebastian@yo.lk}
14
+ s.extra_rdoc_files = [
15
+ "LICENSE",
16
+ "README.rdoc",
17
+ "TODO"
18
+ ]
19
+ s.files = [
20
+ ".gitignore",
21
+ "LICENSE",
22
+ "README.rdoc",
23
+ "Rakefile",
24
+ "TODO",
25
+ "VERSION.yml",
26
+ "generators/memento_migration/memento_migration_generator.rb",
27
+ "generators/memento_migration/templates/migration.rb",
28
+ "lib/memento.rb",
29
+ "lib/memento/action.rb",
30
+ "lib/memento/action/create.rb",
31
+ "lib/memento/action/destroy.rb",
32
+ "lib/memento/action/update.rb",
33
+ "lib/memento/action_controller_methods.rb",
34
+ "lib/memento/active_record_methods.rb",
35
+ "lib/memento/result.rb",
36
+ "lib/memento/session.rb",
37
+ "lib/memento/state.rb",
38
+ "memento.gemspec",
39
+ "rails/init.rb",
40
+ "spec/memento/action/create_spec.rb",
41
+ "spec/memento/action/destroy_spec.rb",
42
+ "spec/memento/action/update_spec.rb",
43
+ "spec/memento/action_controller_methods_spec.rb",
44
+ "spec/memento/active_record_methods_spec.rb",
45
+ "spec/memento/result_spec.rb",
46
+ "spec/memento/session_spec.rb",
47
+ "spec/memento/state_spec.rb",
48
+ "spec/memento_spec.rb",
49
+ "spec/spec_helper.rb"
50
+ ]
51
+ s.homepage = %q{http://github.com/yolk/memento}
52
+ s.rdoc_options = ["--charset=UTF-8"]
53
+ s.require_paths = ["lib"]
54
+ s.rubygems_version = %q{1.3.5}
55
+ s.summary = %q{Undo for Rails/ActiveRecord - covers destroy, update and create}
56
+ s.test_files = [
57
+ "spec/memento/action/create_spec.rb",
58
+ "spec/memento/action/destroy_spec.rb",
59
+ "spec/memento/action/update_spec.rb",
60
+ "spec/memento/action_controller_methods_spec.rb",
61
+ "spec/memento/active_record_methods_spec.rb",
62
+ "spec/memento/result_spec.rb",
63
+ "spec/memento/session_spec.rb",
64
+ "spec/memento/state_spec.rb",
65
+ "spec/memento_spec.rb",
66
+ "spec/spec_helper.rb"
67
+ ]
68
+
69
+ if s.respond_to? :specification_version then
70
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
71
+ s.specification_version = 3
72
+
73
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
74
+ else
75
+ end
76
+ else
77
+ end
78
+ end
79
+
data/rails/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'memento'
@@ -0,0 +1,82 @@
1
+ require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper')
2
+
3
+ describe Memento::Action::Create, "when object is created" do
4
+ before do
5
+ setup_db
6
+ setup_data
7
+ Memento.instance.start(@user)
8
+ @project = Project.create!(:name => "P1", :closed_at => 3.days.ago).reload
9
+ Memento.instance.stop
10
+ end
11
+
12
+ after do
13
+ shutdown_db
14
+ end
15
+
16
+ it "should create memento_state for ar-object with no data" do
17
+ Memento::State.count.should eql(1)
18
+ Memento::State.first.action_type.should eql("create")
19
+ Memento::State.first.record.should eql(@project) # it was destroyed, remember?
20
+ Memento::State.first.reload.record_data.should eql(nil)
21
+ end
22
+
23
+ it "should create object" do
24
+ Project.find_by_id(@project.id).should_not be_nil
25
+ Project.count.should eql(1)
26
+ end
27
+
28
+ it "should allow undoing the creation" do
29
+ Memento::Session.last.undo
30
+ Project.count.should eql(0)
31
+ end
32
+
33
+ describe "when undoing the creation" do
34
+ it "should give back undone_object" do
35
+ Memento::Session.last.undo.map{|e| e.object.class }.should eql([Project])
36
+ end
37
+
38
+ it "should not undo the creatio if object was modified" do
39
+ Project.last.update_attribute(:created_at, 1.minute.ago)
40
+ undone = Memento::Session.last.undo
41
+ Project.count.should eql(1)
42
+ undone.first.should_not be_success
43
+ undone.first.error.should be_was_changed
44
+ end
45
+
46
+ describe "when record was already destroyed" do
47
+ it "should give back fake unsaved record with id set" do
48
+ Project.last.destroy
49
+ @undone = Memento::Session.last.undo
50
+ @undone.size.should eql(1)
51
+ @undone.first.object.should be_kind_of(Project)
52
+ @undone.first.object.id.should eql(@project.id)
53
+ @undone.first.object.name.should be_nil
54
+ @undone.first.object.should be_new_record
55
+ Project.count.should eql(0)
56
+ end
57
+ end
58
+ end
59
+
60
+
61
+
62
+ end
63
+
64
+ describe Memento::Action::Create, "when object without timestamp is created" do
65
+ before do
66
+ setup_db
67
+ setup_data
68
+ Memento.instance.memento(@user) do
69
+ @obj = TimestamplessObject.create!(:name => "O1").reload
70
+ end
71
+ end
72
+
73
+ after do
74
+ shutdown_db
75
+ end
76
+
77
+ describe "when undoing the creation" do
78
+ it "should give back undone_object" do
79
+ Memento::Session.last.undo.map{|e| e.object.class }.should eql([TimestamplessObject])
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,43 @@
1
+ require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper')
2
+
3
+ describe Memento::Action::Destroy, "when object gets destroyed" do
4
+ before do
5
+ setup_db
6
+ setup_data
7
+ @project = Project.create!(:name => "P1", :closed_at => 3.days.ago).reload
8
+ Memento.instance.start(@user)
9
+ @project.destroy
10
+ end
11
+
12
+ after do
13
+ Memento.instance.stop
14
+ shutdown_db
15
+ end
16
+
17
+ it "should create memento_state for ar-object with full attributes_for_memento" do
18
+ Memento::State.count.should eql(1)
19
+ Memento::State.first.action_type.should eql("destroy")
20
+ Memento::State.first.record.should be_nil # it was destroyed, remember?
21
+ Memento::State.first.reload.record_data.should == @project.attributes_for_memento
22
+ end
23
+
24
+ it "should destroy object" do
25
+ Project.find_by_id(@project.id).should be_nil
26
+ Project.count.should be_zero
27
+ end
28
+
29
+ it "should allow undoing the destruction" do
30
+ Project.count.should be_zero
31
+ Memento::Session.last.undo
32
+ Project.count.should eql(1)
33
+ Project.first.attributes_for_memento.reject{|k, v| k.to_sym == :id }.should == (
34
+ @project.attributes_for_memento.reject{|k, v| k.to_sym == :id }
35
+ )
36
+ end
37
+
38
+ it "should give back undone_object on undoing the destruction" do
39
+ Memento::Session.last.undo.map{|e| e.object.class }.should eql([Project])
40
+ end
41
+
42
+
43
+ end
@@ -0,0 +1,213 @@
1
+ require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper')
2
+
3
+ describe Memento::Action::Update do
4
+ before do
5
+ setup_db
6
+ setup_data
7
+ @time1 = 3.days.ago
8
+ @time2 = 2.days.ago
9
+ @project = Project.create!(:name => "P1", :closed_at => @time1, :notes => "Bla bla").reload
10
+ @customer = Customer.create!(:name => "C1")
11
+ end
12
+
13
+ after do
14
+ shutdown_db
15
+ end
16
+
17
+ describe "when object gets updated" do
18
+
19
+ before do
20
+ Memento.instance.memento(@user) do
21
+ @project.update_attributes(:name => "P2", :closed_at => @time2, :customer => @customer, :notes => "Bla bla")
22
+ end
23
+ end
24
+
25
+ it "should create memento_state for ar-object from changes_for_memento" do
26
+ Memento::State.count.should eql(1)
27
+ Memento::State.first.action_type.should eql("update")
28
+ Memento::State.first.record.should eql(@project)
29
+ Memento::State.first.record_data.keys.sort.should eql(%w(name closed_at customer_id).sort)
30
+ Memento::State.first.record_data["name"].should eql(["P1", "P2"])
31
+ Memento::State.first.record_data["customer_id"].should eql([nil, @customer.id])
32
+ Memento::State.first.record_data["closed_at"][0].utc.to_s.should eql(@time1.utc.to_s)
33
+ Memento::State.first.record_data["closed_at"][1].utc.to_s.should eql(@time2.utc.to_s)
34
+ end
35
+
36
+ it "should update object" do
37
+ @project.reload.name.should eql("P2")
38
+ @project.customer.should eql(@customer)
39
+ @project.closed_at.to_s.should eql(@time2.to_s)
40
+ @project.should_not be_changed
41
+ Project.count.should eql(1)
42
+ end
43
+
44
+ it "should allow undoing the update" do
45
+ undone = Memento::Session.last.undo.first
46
+ undone.should be_success
47
+ undone.object.should_not be_changed
48
+ undone.object.name.should eql("P1")
49
+ undone.object.customer.should be_nil
50
+ undone.object.closed_at.to_s.should eql(@time1.to_s)
51
+ end
52
+
53
+ describe "when record was destroyed before undo" do
54
+ before do
55
+ @project.destroy
56
+ end
57
+
58
+ it "should return fake object with error" do
59
+ undone = Memento::Session.last.undo.first
60
+ undone.should_not be_success
61
+ undone.error.should be_was_destroyed
62
+ undone.object.class.should eql(Project)
63
+ undone.object.id.should eql(1)
64
+ end
65
+ end
66
+
67
+ describe "when record was changed before undo" do
68
+
69
+ describe "with mergeable unrecorded changes" do
70
+ before do
71
+ @project.update_attributes({:notes => "Bla!"})
72
+ @result = Memento::Session.first.undo.first
73
+ @object = @result.object
74
+ end
75
+
76
+ it "should be success" do
77
+ @result.should be_success
78
+ end
79
+
80
+ it "should return correctly updated object" do
81
+ @object.class.should eql(Project)
82
+ @object.name.should eql("P1")
83
+ @object.customer.should be_nil
84
+ @object.closed_at.to_s.should eql(@time1.to_s)
85
+ @object.notes.should eql("Bla!")
86
+ end
87
+ end
88
+
89
+ describe "with mergeable recorded changes" do
90
+ before do
91
+ Memento.instance.memento(@user) do
92
+ @project.update_attributes({:notes => "Bla!"})
93
+ end
94
+ Memento::State.last.update_attribute(:created_at, 1.minute.from_now)
95
+ @result = Memento::Session.first.undo.first
96
+ @object = @result.object
97
+ end
98
+
99
+ it "should be success" do
100
+ @result.should be_success
101
+ end
102
+
103
+ it "should return correctly updated object" do
104
+ @object.class.should eql(Project)
105
+ @object.name.should eql("P1")
106
+ @object.customer.should be_nil
107
+ @object.closed_at.to_s.should eql(@time1.to_s)
108
+ @object.notes.should eql("Bla!")
109
+ end
110
+
111
+ describe "when second state is undone" do
112
+ before do
113
+ @result = Memento::Session.first.undo.first
114
+ @object = @result.object
115
+ end
116
+
117
+ it "should be success" do
118
+ @result.should be_success
119
+ end
120
+
121
+ it "should return correctly updated object" do
122
+ @object.class.should eql(Project)
123
+ @object.name.should eql("P1")
124
+ @object.customer.should be_nil
125
+ @object.closed_at.to_s.should eql(@time1.to_s)
126
+ @object.notes.should eql("Bla bla")
127
+ end
128
+ end
129
+ end
130
+
131
+ describe "with unmergeable unrecorded changes" do
132
+ before do
133
+ @project.update_attributes({:name => "P3"})
134
+ @result = Memento::Session.last.undo.first
135
+ @object = @result.object
136
+ end
137
+
138
+ it "should fail" do
139
+ @result.should be_failed
140
+ end
141
+
142
+ it "should set error" do
143
+ @result.error.should be_was_changed
144
+ end
145
+
146
+ it "should return not undone object" do
147
+ @object.name.should eql("P3")
148
+ @object.customer.should eql(@customer)
149
+ @object.closed_at.to_s.should eql(@time2.to_s)
150
+ @object.should_not be_changed
151
+ end
152
+ end
153
+
154
+ describe "with unmergeable recorded changes" do
155
+ before do
156
+ Memento.instance.memento(@user) do
157
+ @project.update_attributes!({:name => "P3"})
158
+ end
159
+ Memento::State.last.update_attribute(:created_at, 1.minute.from_now)
160
+ @result = Memento::Session.first.undo.first
161
+ @object = @result.object
162
+ end
163
+
164
+ it "should fail" do
165
+ @result.should be_failed
166
+ end
167
+
168
+ it "should return not undone object" do
169
+ @object.name.should eql("P3")
170
+ @object.customer.should eql(@customer)
171
+ @object.closed_at.to_s.should eql(@time2.to_s)
172
+ @object.should_not be_changed
173
+ end
174
+
175
+ describe "when second state is undone" do
176
+ before do
177
+ @result = Memento::Session.last.undo.first
178
+ @object = @result.object
179
+ end
180
+
181
+ it "should be success" do
182
+ @result.should be_success
183
+ end
184
+
185
+ it "should return correctly updated object" do
186
+ @object.class.should eql(Project)
187
+ @object.name.should eql("P2")
188
+ @object.customer.should eql(@customer)
189
+ @object.closed_at.to_s.should eql(@time2.to_s)
190
+ @object.notes.should eql("Bla bla")
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ end
197
+
198
+ describe "when object gets updated with no changes" do
199
+
200
+ before do
201
+ Memento.instance.memento(@user) do
202
+ @project.update_attributes(:name => "P1", :customer => nil, :notes => "Bla bla")
203
+ end
204
+ end
205
+
206
+ it "should not create session/state" do
207
+ Memento::Session.count.should eql(0)
208
+ Memento::State.count.should eql(0)
209
+ end
210
+
211
+ end
212
+
213
+ end