memento 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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