has_and_belongs_to_many_with_deferred_save 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ pkg
data/Rakefile ADDED
@@ -0,0 +1,23 @@
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 = 'has_and_belongs_to_many_with_deferred_save'
10
+ Jeweler::Tasks.new do |gem|
11
+ gem.name = project_name
12
+ gem.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."
13
+ gem.email = "github.com@tylerrick.com"
14
+ gem.homepage = "http://github.com/TylerRick/has_and_belongs_to_many_with_deferred_save"
15
+ gem.authors = ["Tyler Rick", "Alessio Caiazza"]
16
+ gem.add_dependency('activerecord')
17
+ gem.add_development_dependency('rspec')
18
+ end
19
+
20
+ Jeweler::GemcutterTasks.new
21
+ rescue LoadError
22
+ puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
23
+ end
data/Readme.markdown ADDED
@@ -0,0 +1,60 @@
1
+ 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.
2
+
3
+ How to install
4
+ ==============
5
+
6
+ As Rails plugin:
7
+ ./script/plugin install git://github.com/TylerRick/has_and_belongs_to_many_with_deferred_save.git
8
+
9
+ As a gem:
10
+ sudo gem install has_and_belongs_to_many_with_deferred_save
11
+
12
+ Usage
13
+ =====
14
+
15
+ class Room < ActiveRecord::Base
16
+ has_and_belongs_to_many_with_deferred_save :people
17
+ end
18
+
19
+ Motivation
20
+ ==========
21
+
22
+ Let's say you want to validate the room.people collection and prevent the user from adding more people to the room than will fit. If they do try to add more people than will fit, you want to display a nice error message on the page and let them try again...
23
+
24
+ This isn't possible using the standard has_and_belongs_to_many due to these two problems:
25
+
26
+ 1. When we do the assignment to our collection (room.people = whatever), it immediately saves it in our join table (people_rooms) rather than waiting until we call room.save.
27
+
28
+ 2. You can "validate" using habtm's :before_add option ... but it any errors added there end up being ignored/lost. The only way to abort the save from a before_add seems to be to raise an exception...
29
+
30
+ But we don't want to raise an exception when the user violates our validation; we want validation of the people collection to be handled the same as any other field in the Room model: we want it to simply add an error to the Room model's error array which we can than display on the form with the other input errors.
31
+
32
+ has_and_belongs_to_many_with_deferred_save solves this problem by overriding the setter method for your collection (people=), causing it to store the new members in a temporary variable (unsaved_people) rather than saving it immediately.
33
+
34
+ You can then validate the unsaved collection as you would any other attribute, adding to self.errors if something is invalid about the collection (too many members, etc.).
35
+
36
+ The unsaved collection is automatically saved when you call save on the model.
37
+
38
+
39
+ Compatibility
40
+ =============
41
+
42
+ Tested with Rails 2.3.4.
43
+
44
+ Bugs
45
+ ====
46
+
47
+ http://github.com/TylerRick/has_and_belongs_to_many_with_deferred_save/issues
48
+
49
+ History
50
+ =======
51
+
52
+ It started as a [post](http://www.ruby-forum.com/topic/81095) to the Rails mailing list asking how to validate a has_and_belongs_to_many collection/association.
53
+
54
+ License
55
+ =======
56
+
57
+ This plugin is licensed under the BSD license.
58
+
59
+ 2010 (c) Contributors
60
+ 2007 (c) QualitySmith, Inc.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,61 @@
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{has_and_belongs_to_many_with_deferred_save}
8
+ s.version = "0.1.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Tyler Rick", "Alessio Caiazza"]
12
+ s.date = %q{2010-02-25}
13
+ s.email = %q{github.com@tylerrick.com}
14
+ s.files = [
15
+ ".gitignore",
16
+ "Rakefile",
17
+ "Readme.markdown",
18
+ "VERSION",
19
+ "has_and_belongs_to_many_with_deferred_save.gemspec",
20
+ "init.rb",
21
+ "install.rb",
22
+ "lib/has_and_belongs_to_many_with_deferred_save.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/person.rb",
28
+ "spec/models/room.rb",
29
+ "spec/spec_helper.rb",
30
+ "uninstall.rb"
31
+ ]
32
+ s.homepage = %q{http://github.com/TylerRick/has_and_belongs_to_many_with_deferred_save}
33
+ s.rdoc_options = ["--charset=UTF-8"]
34
+ s.require_paths = ["lib"]
35
+ s.rubygems_version = %q{1.3.5}
36
+ 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
+ s.test_files = [
38
+ "spec/models/room.rb",
39
+ "spec/models/person.rb",
40
+ "spec/has_and_belongs_to_many_with_deferred_save_spec.rb",
41
+ "spec/spec_helper.rb",
42
+ "spec/db/schema.rb"
43
+ ]
44
+
45
+ if s.respond_to? :specification_version then
46
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
47
+ s.specification_version = 3
48
+
49
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
50
+ s.add_runtime_dependency(%q<activerecord>, [">= 0"])
51
+ s.add_development_dependency(%q<rspec>, [">= 0"])
52
+ else
53
+ s.add_dependency(%q<activerecord>, [">= 0"])
54
+ s.add_dependency(%q<rspec>, [">= 0"])
55
+ end
56
+ else
57
+ s.add_dependency(%q<activerecord>, [">= 0"])
58
+ s.add_dependency(%q<rspec>, [">= 0"])
59
+ end
60
+ end
61
+
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'lib/has_and_belongs_to_many_with_deferred_save')
data/install.rb ADDED
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,133 @@
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
+ # Instructions:
8
+ #
9
+ # Replace your existing call to has_and_belongs_to_many with has_and_belongs_to_many_with_deferred_save.
10
+ #
11
+ # 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.
12
+ #
13
+ # Example:
14
+ #
15
+ # def validate
16
+ # if people.size > maximum_occupancy
17
+ # errors.add :people, "There are too many people in this room"
18
+ # end
19
+ # end
20
+ def has_and_belongs_to_many_with_deferred_save(*args)
21
+ has_and_belongs_to_many *args
22
+ collection_name = args[0].to_s
23
+ collection_singular_ids = collection_name.singularize + "_ids"
24
+
25
+ # this will delete all the assocation into the join table after obj.destroy
26
+ after_destroy { |record| record.save }
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
+
64
+ define_method "before_save_with_deferred_save_for_#{collection_name}" do
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
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
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
+ # (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
+ # 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
73
+ # @use_original_collection_reader_behavior as a switch.
74
+
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
78
+
79
+ self.send "use_original_collection_reader_behavior_for_#{collection_name}=", true
80
+ if self.send("unsaved_#{collection_name}").nil?
81
+ send("initialize_unsaved_#{collection_name}", *args)
82
+ end
83
+ self.send "#{collection_name}_without_deferred_save=", self.send("unsaved_#{collection_name}")
84
+ # /\ This is where the actual save occurs.
85
+ self.send "use_original_collection_reader_behavior_for_#{collection_name}=", false
86
+
87
+ true
88
+ end
89
+ alias_method_chain :"before_save", "deferred_save_for_#{collection_name}"
90
+
91
+
92
+ define_method "reload_with_deferred_save_for_#{collection_name}" do
93
+ # Reload from the *database*, discarding any unsaved changes.
94
+ returning self.send("reload_without_deferred_save_for_#{collection_name}") do
95
+ self.send "unsaved_#{collection_name}=", nil
96
+ # /\ If we didn't do this, then when we called reload, it would still have the same (possibly invalid) value of
97
+ # unsaved_collection that it had before the reload.
98
+ end
99
+ end
100
+ alias_method_chain :"reload", "deferred_save_for_#{collection_name}"
101
+
102
+
103
+ define_method "initialize_unsaved_#{collection_name}" do |*args|
104
+ #puts "Initialized to #{self.send("#{collection_name}_without_deferred_save").clone.inspect}"
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
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
109
+ # will return a new HasAndBelongsToManyAssociation (which acts like an empty array, []).
110
+ # Important: If we don't use clone, then it does an assignment by reference and any changes to unsaved_collection
111
+ # will also change *collection_without_deferred_save*! (Not what we want! Would result in us saving things
112
+ # immediately, which is exactly what we're trying to avoid.)
113
+
114
+ # trick collection_name.include?(obj)
115
+ # If you use a collection of SignelTableInheritance and didn't :select 'type' the
116
+ # include? method will not find any subclassed object.
117
+ class << eval("@unsaved_#{collection_name}")
118
+ def include_with_deferred_save?(obj)
119
+ if self.find { |itm| itm == obj || (itm[:id] == obj[:id] && obj.is_a?(itm.class) ) }
120
+ return true
121
+ else
122
+ return false
123
+ end
124
+ end
125
+ alias_method_chain :include?, 'deferred_save'
126
+ end
127
+ end
128
+ private :"initialize_unsaved_#{collection_name}"
129
+
130
+ end
131
+ end
132
+ end
133
+ 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,21 @@
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
+ end
@@ -0,0 +1,74 @@
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
9
+ @people << Person.create
10
+ @people << Person.create
11
+ @room = Room.new(:maximum_occupancy => 2)
12
+ end
13
+
14
+ it "initial checks" do
15
+ Room .count.should == 0
16
+ Person.count.should == 3
17
+
18
+ @room.people.should == []
19
+ @room.people_without_deferred_save.should == []
20
+ @room.people_without_deferred_save.object_id.should_not ==
21
+ @room.unsaved_people.object_id
22
+ end
23
+
24
+ it "after adding people to room, it should not have saved anything to the database" do
25
+ @room.people << @people[0]
26
+ @room.people << @people[1]
27
+
28
+ # Still not saved to the association table!
29
+ Room.count_by_sql("select count(*) from people_rooms").should == 0
30
+ @room.people_without_deferred_save.size. should == 0
31
+ end
32
+
33
+ it "but room.people.size should still report the current size of 2" do
34
+ @room.people.size.should == 2 # 2 because this looks at unsaved_people and not at the database
35
+ end
36
+
37
+ it "after saving the model, the association should be saved in the join table" do
38
+ @room.save # Only here is it actually saved to the association table!
39
+ @room.errors.full_messages.should == []
40
+ Room.count_by_sql("select count(*) from people_rooms").should == 2
41
+ @room.people.size. should == 2
42
+ @room.people_without_deferred_save.size. should == 2
43
+ end
44
+
45
+ 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
46
+ lambda { @room.people << @people[2] }.should_not raise_error
47
+ @room.people.size. should == 3
48
+
49
+ Room.count_by_sql("select count(*) from people_rooms").should == 2
50
+ @room.valid?
51
+ @room.errors.on(:people).should == "There are too many people in this room"
52
+ @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
+ end
54
+
55
+ it "when we try to save, it should fail, because room.people is still invaild" do
56
+ @room.save.should == false
57
+ 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 == "There are too many people in this room"
59
+ @room.people.size. should == 3
60
+ end
61
+
62
+ it "when we reload, it should go back to only having 2 people in the room" do
63
+ @room.reload
64
+ @room.people.size. should == 2
65
+ @room.people_without_deferred_save.size. should == 2
66
+ end
67
+
68
+ 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
69
+ lambda do
70
+ @room.people_without_deferred_save << @people[2]
71
+ end.should raise_error(RuntimeError)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,3 @@
1
+ class Person < ActiveRecord::Base
2
+ has_and_belongs_to_many :room
3
+ end
@@ -0,0 +1,17 @@
1
+ class Room < ActiveRecord::Base
2
+ has_and_belongs_to_many_with_deferred_save :people, :before_add => :before_adding_person
3
+
4
+ def validate
5
+ if people.size > maximum_occupancy
6
+ errors.add :people, "There are too many people in this room"
7
+ end
8
+ end
9
+
10
+ # Just in case they try to bypass our new accessor and call people_without_deferred_save directly...
11
+ # (This should never be necessary; it is for demonstration purposes only...)
12
+ def before_adding_person(person)
13
+ if self.people_without_deferred_save.size + [person].size > maximum_occupancy
14
+ raise "There are too many people in this room"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib')
2
+ plugin_test_dir = File.dirname(__FILE__)
3
+
4
+ #require 'multi_rails_init'
5
+ require 'active_record'
6
+ # Workaround for https://rails.lighthouseapp.com/projects/8994/tickets/2577-when-using-activerecordassociations-outside-of-rails-a-nameerror-is-thrown
7
+ ActiveRecord::ActiveRecordError
8
+
9
+ require plugin_test_dir + '/../init.rb'
10
+
11
+ ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/test.log")
12
+
13
+ ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
14
+ ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3")
15
+ ActiveRecord::Migration.verbose = false
16
+ load(File.join(plugin_test_dir, "db", "schema.rb"))
17
+
18
+ Dir["#{plugin_test_dir}/models/*.rb"].each {|file| require file }
19
+
20
+ Spec::Runner.configure do |config|
21
+ config.before do
22
+ end
23
+ end
data/uninstall.rb ADDED
@@ -0,0 +1 @@
1
+ # Uninstall hook code here
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: has_and_belongs_to_many_with_deferred_save
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Rick
8
+ - Alessio Caiazza
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2010-02-25 00:00:00 -08:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: activerecord
18
+ type: :runtime
19
+ version_requirement:
20
+ version_requirements: !ruby/object:Gem::Requirement
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ version:
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ type: :development
29
+ version_requirement:
30
+ version_requirements: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ version:
36
+ description:
37
+ email: github.com@tylerrick.com
38
+ executables: []
39
+
40
+ extensions: []
41
+
42
+ extra_rdoc_files: []
43
+
44
+ files:
45
+ - .gitignore
46
+ - Rakefile
47
+ - Readme.markdown
48
+ - VERSION
49
+ - has_and_belongs_to_many_with_deferred_save.gemspec
50
+ - init.rb
51
+ - install.rb
52
+ - lib/has_and_belongs_to_many_with_deferred_save.rb
53
+ - spec/.gitignore
54
+ - spec/db/database.yml
55
+ - spec/db/schema.rb
56
+ - spec/has_and_belongs_to_many_with_deferred_save_spec.rb
57
+ - spec/models/person.rb
58
+ - spec/models/room.rb
59
+ - spec/spec_helper.rb
60
+ - uninstall.rb
61
+ has_rdoc: true
62
+ homepage: http://github.com/TylerRick/has_and_belongs_to_many_with_deferred_save
63
+ licenses: []
64
+
65
+ post_install_message:
66
+ rdoc_options:
67
+ - --charset=UTF-8
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: "0"
75
+ version:
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: "0"
81
+ version:
82
+ requirements: []
83
+
84
+ rubyforge_project:
85
+ rubygems_version: 1.3.5
86
+ signing_key:
87
+ specification_version: 3
88
+ 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
+ test_files:
90
+ - spec/models/room.rb
91
+ - spec/models/person.rb
92
+ - spec/has_and_belongs_to_many_with_deferred_save_spec.rb
93
+ - spec/spec_helper.rb
94
+ - spec/db/schema.rb