deferred_associations 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG ADDED
@@ -0,0 +1,21 @@
1
+ 0.5.0
2
+ =====
3
+ * Added has_many with deferred save, which works like habtm with deferred save
4
+ * Added id setters for AR >= 3.0 compatibility
5
+ * HABTMs are changed in an after_save instead of before_save, dropping the need of
6
+ special before_save call sequences
7
+
8
+ 0.4.0
9
+ =====
10
+ * Added Rails 3.2.2 compatibility
11
+ * used "before_save :callback" instead of redefining "before_save"
12
+
13
+ 0.3.0
14
+ =====
15
+ * method "last" proxies to collections "last" instead of "first"
16
+ * removed singleton methods into a wrapper array
17
+ * renamed to "deferred_associations"
18
+
19
+ 0.2.0
20
+ =====
21
+ * forked from TylerRick
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ task :default do |t|
2
+ options = "--colour"
3
+ files = FileList['spec/**/*_spec.rb'].map{|f| f.sub(%r{^spec/},'') }
4
+ exit system("cd spec && spec #{options} #{files}") ? 0 : 1
5
+ end
6
+
7
+ begin
8
+ require 'jeweler'
9
+ project_name = 'deferred_associations'
10
+ Jeweler::Tasks.new do |gem|
11
+ gem.name = project_name
12
+ gem.summary = "Makes ActiveRecord defer/postpone habtm or has_many associations"
13
+ gem.description = "Makes ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) or has_many
14
+ association until you call model.save, allowing validation in the style of normal attributes. Additionally you
15
+ can check inside before_save filters, if the association was altered."
16
+ gem.homepage = "http://github.com/MartinKoerner/deferred_associations"
17
+ gem.email = "martin.koerner@objectfab.de"
18
+ gem.authors = ["Martin Körner", "Tyler Rick", "Alessio Caiazza"]
19
+ gem.add_dependency('activerecord')
20
+ gem.add_development_dependency('rspec')
21
+ end
22
+
23
+ Jeweler::GemcutterTasks.new
24
+ rescue LoadError
25
+ puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
26
+ end
data/Readme.markdown ADDED
@@ -0,0 +1,70 @@
1
+ Make ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) or has_many association
2
+ until you call model.save, allowing validation in the style of normal attributes.
3
+
4
+ How to install
5
+ ==============
6
+
7
+ gem install deferred_associations
8
+
9
+ Usage
10
+ =====
11
+
12
+ class Room < ActiveRecord::Base
13
+ has_and_belongs_to_many_with_deferred_save :people
14
+ has_many_with_deferred_save :tables
15
+
16
+ validate :usage
17
+ before_save :check_change
18
+
19
+ def usage
20
+ if people.size > 30
21
+ errors.add :people, "There are too many people in this room"
22
+ end
23
+ if tables.size > 15
24
+ errors.add :tables, "There are too many tables in this room"
25
+ end
26
+ # Neither people nor tables are saved to the database, if a validation error is added
27
+ end
28
+
29
+ def check_usage
30
+ # you can check, if there were changes to the association
31
+ if people != people_without_deferred_save
32
+ self.updated_at = Time.now.utc
33
+ end
34
+ end
35
+ end
36
+
37
+ Compatibility
38
+ =============
39
+
40
+ Tested with Rails 2.3.14, 3.2.2
41
+
42
+ Gotchas
43
+ =======
44
+
45
+ Be aware, that the habtm association objects sometimes asks the database instead of giving you the data directly from the array. So you can get something
46
+ like
47
+
48
+ room = Room.new
49
+ room.people << Person.create
50
+ room.people.first # => nil, since the DB doesn't have the association saved yet
51
+
52
+ Bugs
53
+ ====
54
+
55
+ http://github.com/MartinKoerner/deferred_associations/issues
56
+
57
+ History
58
+ ======
59
+
60
+ Most of the code for the habtm association was written by [TylerRick] for his gem [has_and_belongs_to_many_with_deferred](https://github.com/TylerRick/has_and_belongs_to_many_with_deferred)
61
+ Mainly, I changed two things:
62
+ * added ActiveRecord 3 compatibility
63
+ * removed singleton methods, because they interfere with caching
64
+
65
+ License
66
+ =======
67
+
68
+ This plugin is licensed under the BSD license.
69
+
70
+ 2012 (c) Martin Körner
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.5.0
@@ -0,0 +1,63 @@
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{deferred_associations}
8
+ s.version = "0.4.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Martin Koerner", "Tyler Rick", "Alessio Caiazza"]
12
+ s.date = %q{2012-03-18}
13
+ s.files = [
14
+ ".gitignore",
15
+ "Rakefile",
16
+ "Readme.markdown",
17
+ "VERSION",
18
+ "has_and_belongs_to_many_with_deferred_save.gemspec",
19
+ "init.rb",
20
+ "install.rb",
21
+ "lib/has_and_belongs_to_many_with_deferred_save.rb",
22
+ "lib/array_to_association_wrapper.rb",
23
+ "spec/.gitignore",
24
+ "spec/db/database.yml",
25
+ "spec/db/schema.rb",
26
+ "spec/has_and_belongs_to_many_with_deferred_save_spec.rb",
27
+ "spec/models/door.rb",
28
+ "spec/models/person.rb",
29
+ "spec/models/room.rb",
30
+ "spec/spec_helper.rb",
31
+ "uninstall.rb"
32
+ ]
33
+ s.homepage = %q{http://github.com/neogrande/deferred_associations}
34
+ s.rdoc_options = ["--charset=UTF-8"]
35
+ s.require_paths = ["lib"]
36
+ s.rubygems_version = %q{1.3.5}
37
+ s.summary = %q{Make ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) or has_many association until you call model.save, allowing validation in the style of normal attributes.}
38
+ s.test_files = [
39
+ "spec/models/door.rb",
40
+ "spec/models/room.rb",
41
+ "spec/models/person.rb",
42
+ "spec/has_and_belongs_to_many_with_deferred_save_spec.rb",
43
+ "spec/spec_helper.rb",
44
+ "spec/db/schema.rb"
45
+ ]
46
+
47
+ if s.respond_to? :specification_version then
48
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
49
+ s.specification_version = 3
50
+
51
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
52
+ s.add_runtime_dependency(%q<activerecord>, [">= 0"])
53
+ s.add_development_dependency(%q<rspec>, [">= 0"])
54
+ else
55
+ s.add_dependency(%q<activerecord>, [">= 0"])
56
+ s.add_dependency(%q<rspec>, [">= 0"])
57
+ end
58
+ else
59
+ s.add_dependency(%q<activerecord>, [">= 0"])
60
+ s.add_dependency(%q<rspec>, [">= 0"])
61
+ end
62
+ end
63
+
data/init.rb ADDED
@@ -0,0 +1,3 @@
1
+ require File.join(File.dirname(__FILE__), 'lib/array_to_association_wrapper')
2
+ require File.join(File.dirname(__FILE__), 'lib/has_and_belongs_to_many_with_deferred_save')
3
+ require File.join(File.dirname(__FILE__), 'lib/has_many_with_deferred_save')
data/install.rb ADDED
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,67 @@
1
+ class ArrayToAssociationWrapper < Array
2
+
3
+ def defer_association_methods_to owner, association_name
4
+ @association_owner = owner
5
+ @association_name = association_name
6
+ end
7
+
8
+ # trick collection_name.include?(obj)
9
+ # If you use a collection of SingleTableInheritance and didn't :select 'type' the
10
+ # include? method will not find any subclassed object.
11
+ def include_with_deferred_save?(obj)
12
+ if @association_owner.present?
13
+ if self.detect { |itm| itm == obj || (itm[:id] == obj[:id] && obj.is_a?(itm.class)) }
14
+ return true
15
+ else
16
+ return false
17
+ end
18
+ else
19
+ include_without_deferred_save?(obj)
20
+ end
21
+ end
22
+
23
+ alias_method_chain :include?, 'deferred_save'
24
+
25
+ def find_with_deferred_save *args
26
+ if @association_owner.present?
27
+ collection_without_deferred_save.send(:find, *args)
28
+ else
29
+ find_without_deferred_save
30
+ end
31
+ end
32
+
33
+ alias_method_chain :find, :deferred_save
34
+
35
+ def first_with_deferred_save *args
36
+ if @association_owner.present?
37
+ collection_without_deferred_save.send(:first, *args)
38
+ else
39
+ first_without_deferred_save
40
+ end
41
+ end
42
+
43
+ alias_method_chain :first, :deferred_save
44
+
45
+ def last_with_deferred_save *args
46
+ if @association_owner.present?
47
+ collection_without_deferred_save.send(:last, *args)
48
+ else
49
+ last_without_deferred_save
50
+ end
51
+ end
52
+
53
+ alias_method_chain :last, :deferred_save
54
+
55
+ define_method :method_missing do |method, *args|
56
+ #puts "#{self.class}.method_missing(#{method}) (#{collection_without_deferred_save.inspect})"
57
+ if @association_owner.present?
58
+ collection_without_deferred_save.send(method, *args) unless method == :set_inverse_instance
59
+ else
60
+ super
61
+ end
62
+ end
63
+
64
+ def collection_without_deferred_save
65
+ @association_owner.send("#{@association_name}_without_deferred_save")
66
+ end
67
+ end
@@ -0,0 +1,144 @@
1
+ # To do: make it work to call this twice in a class. Currently that probably wouldn't work, because it would try to alias methods to existing names...
2
+ # Note: before_save must be defined *before* including this module, not after.
3
+
4
+ module ActiveRecord
5
+ module Associations
6
+ module ClassMethods
7
+
8
+ # Instructions:
9
+ #
10
+ # Replace your existing call to has_and_belongs_to_many with has_and_belongs_to_many_with_deferred_save.
11
+ #
12
+ # Then add a validation method that adds an error if there is something wrong with the (unsaved) collection. This will prevent it from being saved if there are any errors.
13
+ #
14
+ # Example:
15
+ #
16
+ # def validate
17
+ # if people.size > maximum_occupancy
18
+ # errors.add :people, "There are too many people in this room"
19
+ # end
20
+ # end
21
+ def has_and_belongs_to_many_with_deferred_save(*args)
22
+ has_and_belongs_to_many *args
23
+ collection_name = args[0].to_s
24
+ collection_singular_ids = collection_name.singularize + "_ids"
25
+
26
+ add_deletion_callback
27
+
28
+ attr_accessor :"unsaved_#{collection_name}"
29
+ attr_accessor :"use_original_collection_reader_behavior_for_#{collection_name}"
30
+
31
+ define_method "#{collection_name}_with_deferred_save=" do |collection|
32
+ #puts "has_and_belongs_to_many_with_deferred_save: #{collection_name} = #{collection.collect(&:id).join(',')}"
33
+ self.send "unsaved_#{collection_name}=", collection
34
+ end
35
+
36
+ define_method "#{collection_name}_with_deferred_save" do |*args|
37
+ if self.send("use_original_collection_reader_behavior_for_#{collection_name}")
38
+ self.send("#{collection_name}_without_deferred_save")
39
+ else
40
+ if self.send("unsaved_#{collection_name}").nil?
41
+ send("initialize_unsaved_#{collection_name}", *args)
42
+ end
43
+ self.send("unsaved_#{collection_name}")
44
+ end
45
+ end
46
+
47
+ alias_method_chain :"#{collection_name}=", 'deferred_save'
48
+ alias_method_chain :"#{collection_name}", 'deferred_save'
49
+
50
+ define_method "#{collection_singular_ids}_with_deferred_save" do |*args|
51
+ if self.send("use_original_collection_reader_behavior_for_#{collection_name}")
52
+ self.send("#{collection_singular_ids}_without_deferred_save")
53
+ else
54
+ if self.send("unsaved_#{collection_name}").nil?
55
+ send("initialize_unsaved_#{collection_name}", *args)
56
+ end
57
+ self.send("unsaved_#{collection_name}").map { |e| e[:id] }
58
+ end
59
+ end
60
+
61
+ alias_method_chain :"#{collection_singular_ids}", 'deferred_save'
62
+
63
+ # only needed for ActiveRecord >= 3.0
64
+ if ActiveRecord::VERSION::STRING >= "3"
65
+ define_method "#{collection_singular_ids}_with_deferred_save=" do |ids|
66
+ ids = Array.wrap(ids).reject { |id| id.blank? }
67
+ new_values = self.send("#{collection_name}").klass.find(ids)
68
+ self.send("#{collection_name}=", new_values)
69
+ end
70
+ alias_method_chain :"#{collection_singular_ids}=", 'deferred_save'
71
+ end
72
+
73
+ define_method "do_#{collection_name}_save!" do
74
+ # Question: Why do we need this @use_original_collection_reader_behavior stuff?
75
+ # Answer: Because AssociationCollection#replace(other_array) performs a diff between current_array and other_array and deletes/adds only
76
+ # records that have changed.
77
+ # In order to perform that diff, it needs to figure out what "current_array" is, so it calls our collection_with_deferred_save, not
78
+ # knowing that we've changed its behavior. It expects that method to return the elements of that collection that are in the *database*
79
+ # (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
80
+ # two identical collections so nothing would ever get saved.
81
+ # But we only want the old behavior in this case -- most of the time we want the *new* behavior -- so we use
82
+ # @use_original_collection_reader_behavior as a switch.
83
+
84
+ self.send "use_original_collection_reader_behavior_for_#{collection_name}=", true
85
+ if self.send("unsaved_#{collection_name}").nil?
86
+ send("initialize_unsaved_#{collection_name}")
87
+ end
88
+ self.send "#{collection_name}_without_deferred_save=", self.send("unsaved_#{collection_name}")
89
+ # /\ This is where the actual save occurs.
90
+ self.send "use_original_collection_reader_behavior_for_#{collection_name}=", false
91
+
92
+ true
93
+ end
94
+ after_save "do_#{collection_name}_save!"
95
+
96
+
97
+ define_method "reload_with_deferred_save_for_#{collection_name}" do
98
+ # Reload from the *database*, discarding any unsaved changes.
99
+ self.send("reload_without_deferred_save_for_#{collection_name}").tap do
100
+ self.send "unsaved_#{collection_name}=", nil
101
+ # /\ If we didn't do this, then when we called reload, it would still have the same (possibly invalid) value of
102
+ # unsaved_collection that it had before the reload.
103
+ end
104
+ end
105
+ alias_method_chain :"reload", "deferred_save_for_#{collection_name}"
106
+
107
+
108
+ define_method "initialize_unsaved_#{collection_name}" do |*args|
109
+ #puts "Initialized to #{self.send("#{collection_name}_without_deferred_save").clone.inspect}"
110
+ elements = self.send("#{collection_name}_without_deferred_save", *args).clone
111
+ elements = ArrayToAssociationWrapper.new(elements)
112
+ elements.defer_association_methods_to self, collection_name
113
+ self.send "unsaved_#{collection_name}=", elements
114
+ # /\ We initialize it to collection_without_deferred_save in case they just loaded the object from the
115
+ # database, in which case we want unsaved_collection to start out with the "saved collection".
116
+ # Actually, this doesn't clone the Association but the elements array instead (since the clone method is
117
+ # proxied like any other methods)
118
+ # Important: If we don't use clone, then it does an assignment by reference and any changes to unsaved_collection
119
+ # will also change *collection_without_deferred_save*! (Not what we want! Would result in us saving things
120
+ # immediately, which is exactly what we're trying to avoid.)
121
+
122
+
123
+
124
+ end
125
+ private :"initialize_unsaved_#{collection_name}"
126
+
127
+ end
128
+
129
+ def add_deletion_callback
130
+ # this will delete all the association into the join table after obj.destroy,
131
+ # but is only useful/necessary, if the record is not paranoid?
132
+ unless (self.respond_to?(:paranoid?) && self.paranoid?)
133
+ after_destroy { |record|
134
+ begin
135
+ record.save
136
+ rescue Exception => e
137
+ logger.warn "Association cleanup after destroy failed with #{e}"
138
+ end
139
+ }
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,102 @@
1
+ module ActiveRecord
2
+ module Associations
3
+ module ClassMethods
4
+
5
+ def has_many_with_deferred_save *args
6
+ has_many *args
7
+
8
+ collection_name = args[0].to_s
9
+
10
+ if args[1].is_a?(Hash) && args[1].keys.include?(:through)
11
+ logger.warn "You are using the option :through on #{self.name}##{collection_name}. This was not tested very much with has_many_with_deferred_save. Please write many tests for your functionality!"
12
+ end
13
+
14
+ after_save "hmwds_update_#{collection_name}"
15
+
16
+ define_obj_setter collection_name
17
+ define_obj_getter collection_name
18
+ define_id_setter collection_name
19
+
20
+ define_update_method collection_name
21
+ define_reload_method collection_name
22
+ end
23
+
24
+
25
+ def define_obj_setter collection_name
26
+
27
+ define_method("#{collection_name}_with_deferred_save=") do |objs|
28
+ instance_variable_set "@hmwds_temp_#{collection_name}", objs || []
29
+ end
30
+
31
+ method_name = "#{collection_name}="
32
+ alias_method_chain method_name, :deferred_save
33
+ end
34
+
35
+ def define_obj_getter collection_name
36
+
37
+ define_method("#{collection_name}_with_deferred_save") do
38
+ save_in_progress = instance_variable_get "@hmwds_#{collection_name}_save_in_progress"
39
+
40
+ # while updating the association, rails loads the association object - this needs to be the original one
41
+ unless save_in_progress
42
+ elements = instance_variable_get "@hmwds_temp_#{collection_name}"
43
+ if elements.nil?
44
+ elements = ArrayToAssociationWrapper.new(self.send("#{collection_name}_without_deferred_save"))
45
+ elements.defer_association_methods_to self, collection_name
46
+ instance_variable_set "@hmwds_temp_#{collection_name}", elements
47
+ end
48
+
49
+ result = elements
50
+ else
51
+ result = self.send("#{collection_name}_without_deferred_save")
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ alias_method_chain collection_name, :deferred_save
58
+ end
59
+
60
+ def define_id_setter collection_name
61
+ # only needed for ActiveRecord >= 3.0
62
+ if ActiveRecord::VERSION::STRING >= "3"
63
+ collection_singular_ids = "#{collection_name.singularize}_ids"
64
+ define_method "#{collection_singular_ids}_with_deferred_save=" do |ids|
65
+ ids = Array.wrap(ids).reject { |id| id.blank? }
66
+ new_values = self.send("#{collection_name}").klass.find(ids)
67
+ self.send("#{collection_name}=", new_values)
68
+ end
69
+ alias_method_chain :"#{collection_singular_ids}=", 'deferred_save'
70
+ end
71
+ end
72
+
73
+ def define_update_method collection_name
74
+
75
+ define_method "hmwds_update_#{collection_name}" do
76
+
77
+ unless frozen?
78
+ elements = instance_variable_get "@hmwds_temp_#{collection_name}"
79
+ unless elements.nil? # nothing has been done with the association
80
+ # save is done automatically, if original behaviour is restored
81
+ instance_variable_set "@hmwds_#{collection_name}_save_in_progress", true
82
+ self.send("#{collection_name}_without_deferred_save=", elements)
83
+ instance_variable_set "@hmwds_#{collection_name}_save_in_progress", false
84
+
85
+ instance_variable_set "@hmwds_temp_#{collection_name}", nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ def define_reload_method collection_name
92
+ define_method "reload_with_deferred_save_for_#{collection_name}" do
93
+ # Reload from the *database*, discarding any unsaved changes.
94
+ self.send("reload_without_deferred_save_for_#{collection_name}").tap do
95
+ instance_variable_set "@hmwds_temp_#{collection_name}", nil
96
+ end
97
+ end
98
+ alias_method_chain :"reload", "deferred_save_for_#{collection_name}"
99
+ end
100
+ end
101
+ end
102
+ end
data/spec/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *.log
2
+ *.db
@@ -0,0 +1,21 @@
1
+ sqlite3:
2
+ adapter: sqlite3
3
+ database: test.sqlite3.db
4
+
5
+ sqlite3mem:
6
+ adapter: sqlite3
7
+ database: ":memory:"
8
+
9
+ postgresql:
10
+ adapter: postgresql
11
+ username: postgres
12
+ password: postgres
13
+ database: has_and_belongs_to_many_with_deferred_save_test
14
+ min_messages: ERROR
15
+
16
+ mysql:
17
+ adapter: mysql
18
+ host: localhost
19
+ username: root
20
+ password:
21
+ database: has_and_belongs_to_many_with_deferred_save_test
data/spec/db/schema.rb ADDED
@@ -0,0 +1,40 @@
1
+ # This file is autogenerated. Instead of editing this file, please use the
2
+ # migrations feature of ActiveRecord to incrementally modify your database, and
3
+ # then regenerate this schema definition.
4
+
5
+ ActiveRecord::Schema.define(:version => 1) do
6
+
7
+ create_table "people", :force => true do |t|
8
+ t.column "name", :string
9
+ end
10
+
11
+ create_table "people_rooms", :id => false, :force => true do |t|
12
+ t.column "person_id", :integer
13
+ t.column "room_id", :integer
14
+ end
15
+
16
+ create_table "rooms", :force => true do |t|
17
+ t.column "name", :string
18
+ t.column "maximum_occupancy", :integer
19
+ end
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
+
30
+ create_table "tables", :force => true do |t|
31
+ t.column "name", :string
32
+ t.column "room_id", :integer
33
+ end
34
+
35
+ create_table "chairs", :force => true do |t|
36
+ t.column "name", :string
37
+ t.column "table_id", :integer
38
+ end
39
+
40
+ end
@@ -0,0 +1,214 @@
1
+ require "spec_helper"
2
+ require 'has_and_belongs_to_many_with_deferred_save'
3
+
4
+ describe "has_and_belongs_to_many_with_deferred_save" do
5
+ describe "room maximum_occupancy" do
6
+ before :all do
7
+ @people = []
8
+ @people << Person.create(:name => 'Filbert')
9
+ @people << Person.create(:name => 'Miguel')
10
+ @people << Person.create(:name => 'Rainer')
11
+ @room = Room.new(:maximum_occupancy => 2)
12
+ end
13
+ after :all do
14
+ Person.delete_all
15
+ Room.delete_all
16
+ end
17
+
18
+ it "passes initial checks" do
19
+ Room .count.should == 0
20
+ Person.count.should == 3
21
+
22
+ @room.people.should == []
23
+ @room.people_without_deferred_save.should == []
24
+ @room.people_without_deferred_save.object_id.should_not ==
25
+ @room.unsaved_people.object_id
26
+ end
27
+
28
+ it "after adding people to room, it should not have saved anything to the database" do
29
+ @room.people << @people[0]
30
+ @room.people << @people[1]
31
+
32
+ # Still not saved to the association table!
33
+ Room.count_by_sql("select count(*) from people_rooms").should == 0
34
+ @room.people_without_deferred_save.size. should == 0
35
+ end
36
+
37
+ it "but room.people.size should still report the current size of 2" do
38
+ @room.people.size.should == 2 # 2 because this looks at unsaved_people and not at the database
39
+ end
40
+
41
+ it "after saving the model, the association should be saved in the join table" do
42
+ @room.save # Only here is it actually saved to the association table!
43
+ @room.errors.full_messages.should == []
44
+ Room.count_by_sql("select count(*) from people_rooms").should == 2
45
+ @room.people.size. should == 2
46
+ @room.people_without_deferred_save.size. should == 2
47
+ end
48
+
49
+ it "when we try to add a 3rd person, it should add a validation error to the errors object like any other validation error" do
50
+ lambda { @room.people << @people[2] }.should_not raise_error
51
+ @room.people.size. should == 3
52
+
53
+ Room.count_by_sql("select count(*) from people_rooms").should == 2
54
+ @room.valid?
55
+ @room.get_error(:people).should == "This room has reached its maximum occupancy"
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*.
57
+ end
58
+
59
+ it "when we try to save, it should fail, because room.people is still invalid" do
60
+ @room.save.should == false
61
+ Room.count_by_sql("select count(*) from people_rooms").should == 2 # It's still not there, because it didn't pass the validation.
62
+ @room.get_error(:people).should == "This room has reached its maximum occupancy"
63
+ @room.people.size. should == 3
64
+ @people.map {|p| p.reload; p.rooms.size}.should == [1, 1, 0]
65
+ end
66
+
67
+ it "when we reload, it should go back to only having 2 people in the room" do
68
+ @room.reload
69
+ @room.people.size. should == 2
70
+ @room.people_without_deferred_save.size. should == 2
71
+ @people.map {|p| p.reload; p.rooms.size}. should == [1, 1, 0]
72
+ end
73
+
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
75
+ lambda do
76
+ @room.people_without_deferred_save << @people[2]
77
+ end.should raise_error(RuntimeError)
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
+ obj = @people[2]
103
+ def obj.extra_validation
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
+ obj.class.send(:validate, :extra_validation)
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?.should be_false
118
+ @people[2].get_error(: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.first. should == @people[0]
133
+ @room.people.last. should == @people[1] # @people[2] was removed before
134
+ @room.people.find_by_name('Filbert'). should == @people[0]
135
+ end
136
+
137
+ it "should be dumpable with Marshal" do
138
+ lambda { Marshal.dump(@room.people) }.should_not raise_exception
139
+ lambda { Marshal.dump(Room.new.people) }.should_not raise_exception
140
+ end
141
+
142
+ it "should detect difference in association" do
143
+ @room = Room.find(@room.id)
144
+ @room.bs_diff_before_module.should be_nil
145
+ @room.bs_diff_after_module.should be_nil
146
+ @room.bs_diff_method.should be_nil
147
+
148
+ @room.people.size.should == 2
149
+ @room.people = [@room.people[0]]
150
+ @room.save.should be_true
151
+
152
+ @room.bs_diff_before_module.should be_true
153
+ @room.bs_diff_after_module.should be_true
154
+ if ActiveRecord::VERSION::STRING >= "3"
155
+ @room.bs_diff_method.should be_nil # Rails 3.2: nil (before_save filter is not supported)
156
+ else
157
+ @room.bs_diff_method.should be_true
158
+ end
159
+ end
160
+
161
+ it "should act like original habtm when using ID array with array manipulation" do
162
+ @room = Room.find(@room.id)
163
+ @room.people = [@people[0]]
164
+ @room.save
165
+ @room = Room.find(@room.id) # we don't want to let id and object setters interfere with each other
166
+ @room.people2_ids << @people[1].id
167
+ @room.people2_ids.should == [@people[0].id] # ID array manipulation is ignored
168
+
169
+ @room.person_ids.size.should == 1
170
+ @room.person_ids << @people[1].id
171
+ @room.person_ids.should == [@people[0].id]
172
+ Room.find(@room.id).person_ids.should == [@people[0].id]
173
+ @room.save.should be_true
174
+ Room.find(@room.id).person_ids.should == [@people[0].id] # ID array manipulation is ignored, too
175
+ end
176
+
177
+ it "should work with id setters" do
178
+ @room = Room.find(@room.id)
179
+ @room.people = [@people[0], @people[1]]
180
+ @room.save
181
+ @room = Room.find(@room.id)
182
+ @room.person_ids.should == [@people[0].id, @people[1].id]
183
+ @room.person_ids = [@people[1].id]
184
+ @room.person_ids.should == [@people[1].id]
185
+ Room.find(@room.id).person_ids.should == [@people[0].id,@people[1].id]
186
+ @room.save.should be_true
187
+ Room.find(@room.id).person_ids.should == [@people[1].id]
188
+ end
189
+ end
190
+
191
+ describe "doors" do
192
+ before :all do
193
+ @rooms = []
194
+ @rooms << Room.create(:name => 'Kitchen', :maximum_occupancy => 1)
195
+ @rooms << Room.create(:name => 'Dining room', :maximum_occupancy => 10)
196
+ @door = Door.new(:name => 'Kitchen-Dining-room door')
197
+ end
198
+
199
+ it "passes initial checks" do
200
+ Room.count.should == 2
201
+ Door.count.should == 0
202
+
203
+ @door.rooms.should == []
204
+ @door.rooms_without_deferred_save.should == []
205
+ end
206
+
207
+ it "the association has an include? method" do
208
+ @door.rooms << @rooms[0]
209
+ @door.rooms.include?(@rooms[0]).should be_true
210
+ @door.rooms.include?(@rooms[1]).should be_false
211
+ end
212
+ end
213
+
214
+ end
@@ -0,0 +1,77 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'has_many_with_deferred_save' do
4
+
5
+ before :each do
6
+ @room = Room.create(:maximum_occupancy => 2)
7
+ @table1 = Table.create(:room_id => @room.id)
8
+ @table2 = Table.create
9
+ @chair1 = Chair.create(:table_id => @table1.id, :name => "First")
10
+ @chair2 = Chair.create(:table_id => @table2.id, :name => "Second")
11
+ end
12
+
13
+ it 'should work with tables obj setter/getter' do
14
+ @room.tables.should == [@table1]
15
+ @room.tables = [@table1, @table2]
16
+ Room.find(@room.id).tables.should == [@table1] # not saved yet
17
+ @room.save.should be_true
18
+ Room.find(@room.id).tables.should == [@table1, @table2]
19
+ end
20
+
21
+ it 'should work with tables id setter/getter' do
22
+ @room.table_ids.should == [@table1.id]
23
+ @room.table_ids = [@table1.id, @table2.id]
24
+ Room.find(@room.id).table_ids.should == [@table1.id] # not saved yet
25
+ @room.save.should be_true
26
+ Room.find(@room.id).table_ids.should == [@table1.id, @table2.id]
27
+ end
28
+
29
+ it 'should work with array methods' do
30
+ @room.tables.should == [@table1]
31
+ @room.tables << @table2
32
+ Room.find(@room.id).tables.should == [@table1] # not saved yet
33
+ @room.save.should be_true
34
+ Room.find(@room.id).tables.should == [@table1, @table2]
35
+ @room.tables -= [@table1]
36
+ Room.find(@room.id).tables.should == [@table1, @table2]
37
+ @room.save.should be_true
38
+ Room.find(@room.id).tables.should == [@table2]
39
+ end
40
+
41
+ it 'should reload temporary objects' do
42
+ @room.tables << @table2
43
+ @room.tables.should == [@table1, @table2]
44
+ @room.reload
45
+ @room.tables.should == [@table1]
46
+ end
47
+
48
+ it "should be dumpable with Marshal" do
49
+ lambda { Marshal.dump(@room.tables) }.should_not raise_exception
50
+ lambda { Marshal.dump(Room.new.tables) }.should_not raise_exception
51
+ end
52
+
53
+ describe 'with through option' do
54
+ it 'should have a correct list' do
55
+ # TODO these testcases need to be improved
56
+ @room.chairs.should == [@chair1] # through table1
57
+ @room.tables << @table2
58
+ @room.save.should be_true
59
+ @room.chairs.should == [@chair1] # association doesn't reload itself
60
+ @room.reload
61
+ @room.chairs.should == [@chair1, @chair2]
62
+ end
63
+
64
+ it 'should defer association methods' do
65
+ @room.chairs.first.should == @chair1
66
+ @room.chairs.find(:all, :conditions => {:name => "First"}).should == [@chair1]
67
+ lambda {
68
+ @room.chairs.create(:name => "New one")
69
+ }.should raise_error(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection)
70
+ end
71
+
72
+ it "should be dumpable with Marshal" do
73
+ lambda { Marshal.dump(@room.chairs) }.should_not raise_exception
74
+ lambda { Marshal.dump(Room.new.chairs) }.should_not raise_exception
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,3 @@
1
+ class Chair < ActiveRecord::Base
2
+ belongs_to :table
3
+ end
@@ -0,0 +1,3 @@
1
+ class Door < ActiveRecord::Base
2
+ has_and_belongs_to_many_with_deferred_save :rooms
3
+ end
@@ -0,0 +1,3 @@
1
+ class Person < ActiveRecord::Base
2
+ has_and_belongs_to_many_with_deferred_save :rooms, :validate => true
3
+ end
@@ -0,0 +1,51 @@
1
+ class Room < ActiveRecord::Base
2
+
3
+ attr :bs_diff_before_module, true
4
+ attr :bs_diff_after_module, true
5
+ attr :bs_diff_method, true
6
+
7
+ before_save :diff_before_module
8
+
9
+ has_and_belongs_to_many_with_deferred_save :people, :before_add => :before_adding_person
10
+ has_and_belongs_to_many :people2, :class_name => 'Person'
11
+ has_and_belongs_to_many_with_deferred_save :doors
12
+
13
+ has_many_with_deferred_save :tables
14
+ has_many_with_deferred_save :chairs, :through => :tables #TODO test compatibility with through associations
15
+
16
+ before_save :diff_after_module
17
+
18
+ validate :people_count
19
+
20
+ def people_count
21
+ if people.size > maximum_occupancy
22
+ errors.add :people, "This room has reached its maximum occupancy"
23
+ end
24
+ end
25
+
26
+ # Just in case they try to bypass our new accessor and call people_without_deferred_save directly...
27
+ # (This should never be necessary; it is for demonstration purposes only...)
28
+ def before_adding_person(person)
29
+ if self.people_without_deferred_save.size + [person].size > maximum_occupancy
30
+ raise "There are too many people in this room"
31
+ end
32
+ end
33
+
34
+ def diff_before_module
35
+ #should detect the changes
36
+ self.bs_diff_before_module = (people.size - people_without_deferred_save.size) != 0
37
+ true
38
+ end
39
+
40
+ def diff_after_module
41
+ # should not detect the changes
42
+ self.bs_diff_after_module = (people.size - people_without_deferred_save.size) != 0
43
+ true
44
+ end
45
+
46
+ def before_save
47
+ # old_style, should not detect the changes
48
+ self.bs_diff_method = (people.size - people_without_deferred_save.size) != 0
49
+ true
50
+ end
51
+ end
@@ -0,0 +1,4 @@
1
+ class Table < ActiveRecord::Base
2
+ belongs_to :room
3
+ has_many_with_deferred_save :chairs
4
+ end
@@ -0,0 +1,44 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib')
2
+ plugin_test_dir = File.dirname(__FILE__)
3
+
4
+ require 'rubygems'
5
+ USE_AR_3 = true
6
+
7
+ if defined?(USE_AR_3) && USE_AR_3
8
+ gem 'activerecord', '=3.2.2'
9
+ require 'logger'
10
+ require 'active_record'
11
+ else
12
+ gem 'activerecord', '=2.3.14'
13
+ require 'active_record'
14
+ # Workaround for https://rails.lighthouseapp.com/projects/8994/tickets/2577-when-using-activerecordassociations-outside-of-rails-a-nameerror-is-thrown
15
+ ActiveRecord::ActiveRecordError
16
+ end
17
+
18
+ require plugin_test_dir + '/../init.rb'
19
+
20
+ ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/test.log")
21
+
22
+ ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
23
+ ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
24
+ ActiveRecord::Migration.verbose = false
25
+ load(File.join(plugin_test_dir, "db", "schema.rb"))
26
+
27
+ Dir["#{plugin_test_dir}/models/*.rb"].each {|file| require file }
28
+
29
+ RSpec.configure do |config|
30
+ config.before do
31
+ end
32
+ end
33
+
34
+ class ActiveRecord::Base
35
+
36
+ # Compatibility method for AR 2.3.x and AR 3.2.x
37
+ def get_error attr
38
+ if errors.respond_to?(:on)
39
+ errors.on(attr)
40
+ else
41
+ errors[attr].try(:first)
42
+ end
43
+ end
44
+ end
data/uninstall.rb ADDED
@@ -0,0 +1 @@
1
+ # Uninstall hook code here
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deferred_associations
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.5.0
6
+ platform: ruby
7
+ authors:
8
+ - "Martin K\xC3\xB6rner"
9
+ - Tyler Rick
10
+ - Alessio Caiazza
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+
15
+ date: 2012-03-18 00:00:00 Z
16
+ dependencies:
17
+ - !ruby/object:Gem::Dependency
18
+ name: activerecord
19
+ prerelease: false
20
+ requirement: &id001 !ruby/object:Gem::Requirement
21
+ none: false
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: "0"
26
+ type: :runtime
27
+ version_requirements: *id001
28
+ - !ruby/object:Gem::Dependency
29
+ name: rspec
30
+ prerelease: false
31
+ requirement: &id002 !ruby/object:Gem::Requirement
32
+ none: false
33
+ requirements:
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: "0"
37
+ type: :development
38
+ version_requirements: *id002
39
+ description: |-
40
+ Makes ActiveRecord defer/postpone saving the records you add to an habtm (has_and_belongs_to_many) or has_many
41
+ association until you call model.save, allowing validation in the style of normal attributes. Additionally you
42
+ can check inside before_save filters, if the association was altered.
43
+ email: martin.koerner@objectfab.de
44
+ executables: []
45
+
46
+ extensions: []
47
+
48
+ extra_rdoc_files: []
49
+
50
+ files:
51
+ - CHANGELOG
52
+ - Rakefile
53
+ - Readme.markdown
54
+ - VERSION
55
+ - has_and_belongs_to_many_with_deferred_save.gemspec
56
+ - init.rb
57
+ - install.rb
58
+ - lib/array_to_association_wrapper.rb
59
+ - lib/has_and_belongs_to_many_with_deferred_save.rb
60
+ - lib/has_many_with_deferred_save.rb
61
+ - spec/.gitignore
62
+ - spec/db/database.yml
63
+ - spec/db/schema.rb
64
+ - spec/has_and_belongs_to_many_with_deferred_save_spec.rb
65
+ - spec/has_many_with_deferred_save_spec.rb
66
+ - spec/models/chair.rb
67
+ - spec/models/door.rb
68
+ - spec/models/person.rb
69
+ - spec/models/room.rb
70
+ - spec/models/table.rb
71
+ - spec/spec_helper.rb
72
+ - uninstall.rb
73
+ homepage: http://github.com/MartinKoerner/deferred_associations
74
+ licenses: []
75
+
76
+ post_install_message:
77
+ rdoc_options: []
78
+
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: "0"
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: "0"
93
+ requirements: []
94
+
95
+ rubyforge_project:
96
+ rubygems_version: 1.8.15
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: Makes ActiveRecord defer/postpone habtm or has_many associations
100
+ test_files: []
101
+