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