has_and_belongs_to_many_with_deferred_save 0.1.0 → 0.2.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.
- data/.gitignore +1 -0
- data/VERSION +1 -1
- data/has_and_belongs_to_many_with_deferred_save.gemspec +4 -2
- data/lib/has_and_belongs_to_many_with_deferred_save.rb +35 -12
- data/spec/db/schema.rb +9 -0
- data/spec/has_and_belongs_to_many_with_deferred_save_spec.rb +91 -6
- data/spec/models/door.rb +3 -0
- data/spec/models/person.rb +1 -1
- data/spec/models/room.rb +3 -1
- metadata +3 -1
data/.gitignore
CHANGED
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.2.0
|
@@ -5,7 +5,7 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{has_and_belongs_to_many_with_deferred_save}
|
8
|
-
s.version = "0.
|
8
|
+
s.version = "0.2.0"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Tyler Rick", "Alessio Caiazza"]
|
@@ -24,6 +24,7 @@ Gem::Specification.new do |s|
|
|
24
24
|
"spec/db/database.yml",
|
25
25
|
"spec/db/schema.rb",
|
26
26
|
"spec/has_and_belongs_to_many_with_deferred_save_spec.rb",
|
27
|
+
"spec/models/door.rb",
|
27
28
|
"spec/models/person.rb",
|
28
29
|
"spec/models/room.rb",
|
29
30
|
"spec/spec_helper.rb",
|
@@ -35,7 +36,8 @@ Gem::Specification.new do |s|
|
|
35
36
|
s.rubygems_version = %q{1.3.5}
|
36
37
|
s.summary = %q{Make ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) association until you call model.save, allowing validation in the style of normal attributes.}
|
37
38
|
s.test_files = [
|
38
|
-
"spec/models/
|
39
|
+
"spec/models/door.rb",
|
40
|
+
"spec/models/room.rb",
|
39
41
|
"spec/models/person.rb",
|
40
42
|
"spec/has_and_belongs_to_many_with_deferred_save_spec.rb",
|
41
43
|
"spec/spec_helper.rb",
|
@@ -21,7 +21,7 @@ module ActiveRecord
|
|
21
21
|
has_and_belongs_to_many *args
|
22
22
|
collection_name = args[0].to_s
|
23
23
|
collection_singular_ids = collection_name.singularize + "_ids"
|
24
|
-
|
24
|
+
|
25
25
|
# this will delete all the assocation into the join table after obj.destroy
|
26
26
|
after_destroy { |record| record.save }
|
27
27
|
|
@@ -63,18 +63,18 @@ module ActiveRecord
|
|
63
63
|
|
64
64
|
define_method "before_save_with_deferred_save_for_#{collection_name}" do
|
65
65
|
# Question: Why do we need this @use_original_collection_reader_behavior stuff?
|
66
|
-
# Answer: Because AssociationCollection#replace(other_array) performs a diff between current_array and other_array and deletes/adds only
|
66
|
+
# Answer: Because AssociationCollection#replace(other_array) performs a diff between current_array and other_array and deletes/adds only
|
67
67
|
# records that have changed.
|
68
|
-
# In order to perform that diff, it needs to figure out what "current_array" is, so it calls our collection_with_deferred_save, not
|
68
|
+
# In order to perform that diff, it needs to figure out what "current_array" is, so it calls our collection_with_deferred_save, not
|
69
69
|
# knowing that we've changed its behavior. It expects that method to return the elements of that collection that are in the *database*
|
70
70
|
# (the original behavior), so we have to provide that behavior... If we didn't provide it, it would end up trying to take the diff of
|
71
71
|
# two identical collections so nothing would ever get saved.
|
72
|
-
# But we only want the old behavior in this case -- most of the time we want the *new* behavior -- so we use
|
72
|
+
# But we only want the old behavior in this case -- most of the time we want the *new* behavior -- so we use
|
73
73
|
# @use_original_collection_reader_behavior as a switch.
|
74
|
-
|
74
|
+
|
75
75
|
if self.respond_to? :"before_save_without_deferred_save_for_#{collection_name}"
|
76
|
-
self.send("before_save_without_deferred_save_for_#{collection_name}")
|
77
|
-
end
|
76
|
+
self.send("before_save_without_deferred_save_for_#{collection_name}")
|
77
|
+
end
|
78
78
|
|
79
79
|
self.send "use_original_collection_reader_behavior_for_#{collection_name}=", true
|
80
80
|
if self.send("unsaved_#{collection_name}").nil?
|
@@ -103,20 +103,20 @@ module ActiveRecord
|
|
103
103
|
define_method "initialize_unsaved_#{collection_name}" do |*args|
|
104
104
|
#puts "Initialized to #{self.send("#{collection_name}_without_deferred_save").clone.inspect}"
|
105
105
|
self.send "unsaved_#{collection_name}=", self.send("#{collection_name}_without_deferred_save", *args).clone
|
106
|
-
# /\ We initialize it to collection_without_deferred_save in case they just loaded the object from the
|
106
|
+
# /\ We initialize it to collection_without_deferred_save in case they just loaded the object from the
|
107
107
|
# database, in which case we want unsaved_collection to start out with the "saved collection".
|
108
|
-
# If they just constructed a *new* object, this will still work, because self.collection_without_deferred_save.clone
|
108
|
+
# If they just constructed a *new* object, this will still work, because self.collection_without_deferred_save.clone
|
109
109
|
# will return a new HasAndBelongsToManyAssociation (which acts like an empty array, []).
|
110
110
|
# Important: If we don't use clone, then it does an assignment by reference and any changes to unsaved_collection
|
111
111
|
# will also change *collection_without_deferred_save*! (Not what we want! Would result in us saving things
|
112
112
|
# immediately, which is exactly what we're trying to avoid.)
|
113
|
-
|
113
|
+
|
114
114
|
# trick collection_name.include?(obj)
|
115
115
|
# If you use a collection of SignelTableInheritance and didn't :select 'type' the
|
116
116
|
# include? method will not find any subclassed object.
|
117
117
|
class << eval("@unsaved_#{collection_name}")
|
118
118
|
def include_with_deferred_save?(obj)
|
119
|
-
if self.
|
119
|
+
if self.detect { |itm| itm == obj || (itm[:id] == obj[:id] && obj.is_a?(itm.class) ) }
|
120
120
|
return true
|
121
121
|
else
|
122
122
|
return false
|
@@ -124,9 +124,32 @@ module ActiveRecord
|
|
124
124
|
end
|
125
125
|
alias_method_chain :include?, 'deferred_save'
|
126
126
|
end
|
127
|
+
|
128
|
+
|
129
|
+
collection_without_deferred_save = self.send("#{collection_name}_without_deferred_save")
|
130
|
+
# (We don't have access to locals inside a normal class << object block, so we have to do it this way instead.)
|
131
|
+
(class << eval("@unsaved_#{collection_name}"); self end).class_eval do
|
132
|
+
define_method :find do |*args|
|
133
|
+
collection_without_deferred_save.send(:find, *args)
|
134
|
+
end
|
135
|
+
# We have to override these so that it doesn't call Array's version of these methods.
|
136
|
+
# Otherwise user will get a "can't convert Hash into Integer" error
|
137
|
+
define_method :first do |*args|
|
138
|
+
collection_without_deferred_save.send(:first, *args)
|
139
|
+
end
|
140
|
+
define_method :last do |*args|
|
141
|
+
collection_without_deferred_save.send(:first, *args)
|
142
|
+
end
|
143
|
+
|
144
|
+
define_method :method_missing do |method, *args|
|
145
|
+
#puts "#{self.class}.method_missing(#{method}) (#{collection_without_deferred_save.inspect})"
|
146
|
+
collection_without_deferred_save.send(method, *args)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
127
150
|
end
|
128
151
|
private :"initialize_unsaved_#{collection_name}"
|
129
|
-
|
152
|
+
|
130
153
|
end
|
131
154
|
end
|
132
155
|
end
|
data/spec/db/schema.rb
CHANGED
@@ -18,4 +18,13 @@ ActiveRecord::Schema.define(:version => 1) do
|
|
18
18
|
t.column "maximum_occupancy", :integer
|
19
19
|
end
|
20
20
|
|
21
|
+
create_table "doors_rooms", :id => false, :force => true do |t|
|
22
|
+
t.column "door_id", :integer
|
23
|
+
t.column "room_id", :integer
|
24
|
+
end
|
25
|
+
|
26
|
+
create_table "doors", :force => true do |t|
|
27
|
+
t.column "name", :string
|
28
|
+
end
|
29
|
+
|
21
30
|
end
|
@@ -5,13 +5,17 @@ describe "has_and_belongs_to_many_with_deferred_save" do
|
|
5
5
|
describe "room maximum_occupancy" do
|
6
6
|
before :all do
|
7
7
|
@people = []
|
8
|
-
@people << Person.create
|
9
|
-
@people << Person.create
|
10
|
-
@people << Person.create
|
8
|
+
@people << Person.create(:name => 'Filbert')
|
9
|
+
@people << Person.create(:name => 'Miguel')
|
10
|
+
@people << Person.create(:name => 'Rainer')
|
11
11
|
@room = Room.new(:maximum_occupancy => 2)
|
12
12
|
end
|
13
|
+
after :all do
|
14
|
+
Person.delete_all
|
15
|
+
Room.delete_all
|
16
|
+
end
|
13
17
|
|
14
|
-
it "initial checks" do
|
18
|
+
it "passes initial checks" do
|
15
19
|
Room .count.should == 0
|
16
20
|
Person.count.should == 3
|
17
21
|
|
@@ -48,21 +52,23 @@ describe "has_and_belongs_to_many_with_deferred_save" do
|
|
48
52
|
|
49
53
|
Room.count_by_sql("select count(*) from people_rooms").should == 2
|
50
54
|
@room.valid?
|
51
|
-
@room.errors.on(:people).should == "
|
55
|
+
@room.errors.on(:people).should == "This room has reached its maximum occupancy"
|
52
56
|
@room.people.size. should == 3 # Just like with normal attributes that fail validation... the attribute still contains the invalid data but we refuse to save until it is changed to something that is *valid*.
|
53
57
|
end
|
54
58
|
|
55
59
|
it "when we try to save, it should fail, because room.people is still invaild" do
|
56
60
|
@room.save.should == false
|
57
61
|
Room.count_by_sql("select count(*) from people_rooms").should == 2 # It's still not there, because it didn't pass the validation.
|
58
|
-
@room.errors.on(:people).should == "
|
62
|
+
@room.errors.on(:people).should == "This room has reached its maximum occupancy"
|
59
63
|
@room.people.size. should == 3
|
64
|
+
@people.map {|p| p.reload; p.rooms.size}.should == [1, 1, 0]
|
60
65
|
end
|
61
66
|
|
62
67
|
it "when we reload, it should go back to only having 2 people in the room" do
|
63
68
|
@room.reload
|
64
69
|
@room.people.size. should == 2
|
65
70
|
@room.people_without_deferred_save.size. should == 2
|
71
|
+
@people.map {|p| p.reload; p.rooms.size}. should == [1, 1, 0]
|
66
72
|
end
|
67
73
|
|
68
74
|
it "if they try to go around our accessors and use the original accessors, then (and only then) will the exception be raised in before_adding_person..." do
|
@@ -70,5 +76,84 @@ describe "has_and_belongs_to_many_with_deferred_save" do
|
|
70
76
|
@room.people_without_deferred_save << @people[2]
|
71
77
|
end.should raise_error(RuntimeError)
|
72
78
|
end
|
79
|
+
|
80
|
+
it "lets you bypass the validation on Room if we add the association from the other side (person.rooms <<)?" do
|
81
|
+
@people[2].rooms << @room
|
82
|
+
@people[2].rooms.size.should == 1
|
83
|
+
|
84
|
+
# Adding it from one direction does not add it to the other object's association (@room.people), so the validation passes.
|
85
|
+
@room.reload.people.size.should == 2
|
86
|
+
@people[2].valid?
|
87
|
+
@people[2].errors.full_messages.should == []
|
88
|
+
@people[2].save.should == true
|
89
|
+
|
90
|
+
# It is only after reloading that @room.people has this 3rd object, causing it to be invalid, and by then it's too late to do anything about it.
|
91
|
+
@room.reload.people.size.should == 3
|
92
|
+
@room.valid?.should == false
|
93
|
+
end
|
94
|
+
|
95
|
+
it "only if you add the validation to both sides, can you ensure that the size of the association does not exceed some limit" do
|
96
|
+
@room.reload.people.size.should == 3
|
97
|
+
@room.people.delete(@people[2])
|
98
|
+
@room.save.should == true
|
99
|
+
@room.reload.people.size.should == 2
|
100
|
+
@people[2].reload.rooms.size.should == 0
|
101
|
+
|
102
|
+
class << @people[2]
|
103
|
+
def validate
|
104
|
+
rooms.each do |room|
|
105
|
+
this_room_unsaved = rooms_without_deferred_save.include?(room) ? 0 : 1
|
106
|
+
if room.people.size + this_room_unsaved > room.maximum_occupancy
|
107
|
+
errors.add :rooms, "This room has reached its maximum occupancy"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
@people[2].rooms << @room
|
114
|
+
@people[2].rooms.size.should == 1
|
115
|
+
|
116
|
+
@room.reload.people.size.should == 2
|
117
|
+
@people[2].valid?
|
118
|
+
@people[2].errors.on(:rooms).should == "This room has reached its maximum occupancy"
|
119
|
+
@room.reload.people.size.should == 2
|
120
|
+
end
|
121
|
+
|
122
|
+
it "still lets you do find" do
|
123
|
+
#@room.people2. find(:first, :conditions => {:name => 'Filbert'}).should == @people[0]
|
124
|
+
#@room.people_without_deferred_save.find(:first, :conditions => {:name => 'Filbert'}).should == @people[0]
|
125
|
+
#@room.people2.first(:conditions => {:name => 'Filbert'}).should == @people[0]
|
126
|
+
#@room.people_without_deferred_save.first(:conditions => {:name => 'Filbert'}).should == @people[0]
|
127
|
+
#@room.people_without_deferred_save.find_by_name('Filbert').should == @people[0]
|
128
|
+
|
129
|
+
@room.people.find(:first, :conditions => {:name => 'Filbert'}).should == @people[0]
|
130
|
+
@room.people.first(:conditions => {:name => 'Filbert'}). should == @people[0]
|
131
|
+
@room.people.last(:conditions => {:name => 'Filbert'}). should == @people[0]
|
132
|
+
@room.people.find_by_name('Filbert'). should == @people[0]
|
133
|
+
end
|
73
134
|
end
|
135
|
+
|
136
|
+
describe "doors" do
|
137
|
+
before :all do
|
138
|
+
@rooms = []
|
139
|
+
@rooms << Room.create(:name => 'Kitchen', :maximum_occupancy => 1)
|
140
|
+
@rooms << Room.create(:name => 'Dining room', :maximum_occupancy => 10)
|
141
|
+
@door = Door.new(:name => 'Kitchen-Dining-room door')
|
142
|
+
end
|
143
|
+
|
144
|
+
it "passes initial checks" do
|
145
|
+
Room.count.should == 2
|
146
|
+
Door.count.should == 0
|
147
|
+
|
148
|
+
@door.rooms.should == []
|
149
|
+
@door.rooms_without_deferred_save.should == []
|
150
|
+
end
|
151
|
+
|
152
|
+
it "the association has an include? method" do
|
153
|
+
@door.rooms << @rooms[0]
|
154
|
+
@door.rooms.include?(@rooms[0]).should be_true
|
155
|
+
@door.rooms.include?(@rooms[1]).should be_false
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
74
159
|
end
|
data/spec/models/door.rb
ADDED
data/spec/models/person.rb
CHANGED
data/spec/models/room.rb
CHANGED
@@ -1,9 +1,11 @@
|
|
1
1
|
class Room < ActiveRecord::Base
|
2
2
|
has_and_belongs_to_many_with_deferred_save :people, :before_add => :before_adding_person
|
3
|
+
has_and_belongs_to_many :people2, :class_name => 'Person'
|
4
|
+
has_and_belongs_to_many_with_deferred_save :doors
|
3
5
|
|
4
6
|
def validate
|
5
7
|
if people.size > maximum_occupancy
|
6
|
-
errors.add :people, "
|
8
|
+
errors.add :people, "This room has reached its maximum occupancy"
|
7
9
|
end
|
8
10
|
end
|
9
11
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: has_and_belongs_to_many_with_deferred_save
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tyler Rick
|
@@ -54,6 +54,7 @@ files:
|
|
54
54
|
- spec/db/database.yml
|
55
55
|
- spec/db/schema.rb
|
56
56
|
- spec/has_and_belongs_to_many_with_deferred_save_spec.rb
|
57
|
+
- spec/models/door.rb
|
57
58
|
- spec/models/person.rb
|
58
59
|
- spec/models/room.rb
|
59
60
|
- spec/spec_helper.rb
|
@@ -87,6 +88,7 @@ signing_key:
|
|
87
88
|
specification_version: 3
|
88
89
|
summary: Make ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) association until you call model.save, allowing validation in the style of normal attributes.
|
89
90
|
test_files:
|
91
|
+
- spec/models/door.rb
|
90
92
|
- spec/models/room.rb
|
91
93
|
- spec/models/person.rb
|
92
94
|
- spec/has_and_belongs_to_many_with_deferred_save_spec.rb
|