ryanb-association-freezer 0.1.1

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/CHANGELOG ADDED
@@ -0,0 +1,13 @@
1
+ *0.1.1* (July 14th, 2008)
2
+
3
+ * frozen record is no longer considered new
4
+
5
+ * frozen record keeps id attribute of old record
6
+
7
+ * don't raise exception when trying to freeze nil association
8
+
9
+ * raise an exception when trying to replace frozen association
10
+
11
+ *0.1.0* (July 14th, 2008)
12
+
13
+ * initial release
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Ryan Bates
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Manifest ADDED
@@ -0,0 +1,16 @@
1
+ association-freezer.gemspec
2
+ CHANGELOG
3
+ lib/association_freezer/belongs_to_freezer.rb
4
+ lib/association_freezer/method_generator.rb
5
+ lib/association_freezer/model_additions.rb
6
+ lib/association_freezer.rb
7
+ LICENSE
8
+ Manifest
9
+ README
10
+ spec/freezer_spec.rb
11
+ spec/lib/order.rb
12
+ spec/lib/ship_method.rb
13
+ spec/spec_helper.rb
14
+ tasks/deployment.rake
15
+ tasks/spec.rake
16
+ tmp/test.sqlite3
data/README ADDED
@@ -0,0 +1,110 @@
1
+ = Association Freezer
2
+
3
+ Freeze an Active Record belongs_to association to ignore any future changes.
4
+
5
+
6
+ == Install
7
+
8
+ First specify it in your Rails config.
9
+
10
+ config.gem 'ryanb-association-freezer', :lib => 'association_freezer', :source => 'http://gems.github.com'
11
+
12
+ And then install it.
13
+
14
+ rake gems:install
15
+
16
+ Follow the instructions below to finish setup. Rails 2.1 or later
17
+ required.
18
+
19
+
20
+ == Scenario
21
+
22
+ Let's say we have an Order model with a couple belongs_to associations
23
+ for setting the billing and shipping address.
24
+
25
+ class Order < ActiveRecord::Base
26
+ belongs_to :shipping_address, :class_name => 'Address'
27
+ belongs_to :billing_address, :class_name => 'Address'
28
+ # ...
29
+ end
30
+
31
+ Consider this scenario. A user comes to the site and places an order.
32
+ Then he comes back a month later and changes his address. Now our Order
33
+ model is incorrect, it will point to this new address instead of the
34
+ old one he placed the order with.
35
+
36
+ There's a couple ways to solve this, one is to create a new Address
37
+ model whenever one edits the address. Another is to freeze the address
38
+ attributes upon placing an order so it isn't effected when the original
39
+ Address changes. This gem will help with the latter.
40
+
41
+
42
+ == Usage
43
+
44
+ First you need to generate a binary (or blob) column to hold each
45
+ frozen assoiation. The name of the column must start with "frozen_" and
46
+ end with the association name. You can do that with a migration like
47
+ this:
48
+
49
+ script/generate migration add_frozen_addresses_to_orders \
50
+ frozen_billing_address:binary frozen_shipping_address:binary
51
+
52
+
53
+ Next, enable the association freezer on your model. This must be done
54
+ AFTER the associations have been specified.
55
+
56
+ class Order < ActiveRecord::Base
57
+ belongs_to :shipping_address, :class_name => 'Address'
58
+ belongs_to :billing_address, :class_name => 'Address'
59
+ enable_association_freezer
60
+ #...
61
+ end
62
+
63
+ Now you can freeze the association at will:
64
+
65
+ # in order
66
+ def purchase
67
+ #...
68
+ freeze_billing_address
69
+ freeze_shipping_address
70
+ save!
71
+ end
72
+
73
+ When freezing an association, the attributes will be set to the frozen
74
+ column. However, the model is not saved automatically, so you must call
75
+ save at some point after freezing.
76
+
77
+ Here's where the magic starts. Now when you fetch the frozen
78
+ association (order.billing_address), the association freezer will step
79
+ in and return an Address model which is built from the attributes in
80
+ the frozen column, not the attributes in the addresses table.
81
+ Therefore, any changes to the address will not effect the order's
82
+ address. "freeze" is called on the model before returning it to prevent
83
+ accidentally altering the data.
84
+
85
+ You can also unfreeze an already frozen association if you want it to
86
+ use the database record instead.
87
+
88
+ unfreeze_billing_address
89
+
90
+ You can then freeze it again to use the updated attributes.
91
+
92
+
93
+ == Alternative Usage
94
+
95
+ Not only is this gem good at handling the situation above, but it can
96
+ also be used as a caching technique. If the belongs_to association is
97
+ frequently accessed, it can be more efficient to freeze (cache) it to
98
+ remove the need for a database call. Of course with this approach you
99
+ want it to stay in sync with the database, so you'll need to expire the
100
+ cache (refreeze the association) whenever the associated model changes.
101
+
102
+
103
+ == Development
104
+
105
+ This project can be found on github at the following URL.
106
+
107
+ http://github.com/ryanb/association-freezer/
108
+
109
+ If you would like to contribute to this project, please fork the
110
+ repository and send me a pull request.
@@ -0,0 +1,50 @@
1
+
2
+ # Gem::Specification for Association-freezer-0.1.1
3
+ # Originally generated by Echoe
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = %q{association-freezer}
7
+ s.version = "0.1.1"
8
+
9
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
10
+ s.authors = ["Ryan Bates"]
11
+ s.date = %q{2008-07-24}
12
+ s.description = %q{Freeze an Active Record belongs_to association.}
13
+ s.email = %q{ryan (at) railscasts (dot) com}
14
+ s.extra_rdoc_files = ["CHANGELOG", "lib/association_freezer/belongs_to_freezer.rb", "lib/association_freezer/method_generator.rb", "lib/association_freezer/model_additions.rb", "lib/association_freezer.rb", "LICENSE", "README", "tasks/deployment.rake", "tasks/spec.rake"]
15
+ s.files = ["association-freezer.gemspec", "CHANGELOG", "lib/association_freezer/belongs_to_freezer.rb", "lib/association_freezer/method_generator.rb", "lib/association_freezer/model_additions.rb", "lib/association_freezer.rb", "LICENSE", "Manifest", "README", "spec/freezer_spec.rb", "spec/lib/order.rb", "spec/lib/ship_method.rb", "spec/spec_helper.rb", "tasks/deployment.rake", "tasks/spec.rake", "tmp/test.sqlite3"]
16
+ s.has_rdoc = true
17
+ s.homepage = %q{http://github.com/ryanb/association-freezer}
18
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Association-freezer", "--main", "README"]
19
+ s.require_paths = ["lib"]
20
+ s.rubyforge_project = %q{association-freezer}
21
+ s.rubygems_version = %q{1.2.0}
22
+ s.summary = %q{Freeze an Active Record belongs_to association.}
23
+
24
+ if s.respond_to? :specification_version then
25
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
26
+ s.specification_version = 2
27
+ if current_version >= 3 then
28
+ else
29
+ end
30
+ else
31
+ end
32
+ end
33
+
34
+
35
+ # # Original Rakefile source (requires the Echoe gem):
36
+ #
37
+ # require 'rubygems'
38
+ # require 'rake'
39
+ # require 'echoe'
40
+ #
41
+ # Echoe.new('association-freezer', '0.1.1') do |p|
42
+ # p.summary = "Freeze an Active Record belongs_to association."
43
+ # p.description = "Freeze an Active Record belongs_to association."
44
+ # p.url = "http://github.com/ryanb/association-freezer"
45
+ # p.author = 'Ryan Bates'
46
+ # p.email = "ryan (at) railscasts (dot) com"
47
+ # p.ignore_pattern = ["script/*"]
48
+ # end
49
+ #
50
+ # Dir["#{File.dirname(__FILE__)}/tasks/*.rake"].sort.each { |ext| load ext }
@@ -0,0 +1,60 @@
1
+ module AssociationFreezer
2
+ class BelongsToFreezer
3
+ def initialize(owner, reflection)
4
+ @owner = owner
5
+ @reflection = reflection
6
+ end
7
+
8
+ def freeze
9
+ self.frozen_data = Marshal.dump(nonfrozen.attributes) if nonfrozen
10
+ end
11
+
12
+ def unfreeze
13
+ @frozen = nil
14
+ self.frozen_data = nil
15
+ end
16
+
17
+ def fetch(*args)
18
+ frozen || nonfrozen(*args)
19
+ end
20
+
21
+ def frozen?
22
+ frozen_data
23
+ end
24
+
25
+ private
26
+
27
+ def frozen
28
+ @frozen ||= load_frozen if frozen?
29
+ end
30
+
31
+ def load_frozen
32
+ attributes = Marshal.load(frozen_data)
33
+ target = target_class.new(attributes.except('id'))
34
+ target.id = attributes['id']
35
+ target.instance_variable_set('@new_record', false)
36
+ target.readonly!
37
+ target.freeze
38
+ end
39
+
40
+ def nonfrozen(*args)
41
+ @owner.send("#{name}_without_frozen_check", *args)
42
+ end
43
+
44
+ def frozen_data=(data)
45
+ @owner.write_attribute("frozen_#{name}", data)
46
+ end
47
+
48
+ def frozen_data
49
+ @owner.read_attribute("frozen_#{name}")
50
+ end
51
+
52
+ def target_class
53
+ @reflection.klass
54
+ end
55
+
56
+ def name
57
+ @reflection.name
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,63 @@
1
+ module AssociationFreezer
2
+ class MethodGenerator
3
+
4
+ def initialize(reflection)
5
+ @reflection = reflection
6
+ end
7
+
8
+ def generate
9
+ # it is very important that we first make sure this hasn't already been done
10
+ # because otherwise it will result in an endless loop the way alias method works.
11
+ return if previously_generated? || !frozen_column_exists?
12
+
13
+ reflection = @reflection
14
+ freezer = "#{reflection.name}_freezer"
15
+
16
+ generate_method freezer do
17
+ read_attribute("@#{freezer}") || write_attribute("@#{freezer}", BelongsToFreezer.new(self, reflection))
18
+ end
19
+
20
+ generate_method "freeze_#{reflection.name}" do
21
+ send(freezer).freeze
22
+ end
23
+
24
+ generate_method "unfreeze_#{reflection.name}" do
25
+ send(freezer).unfreeze
26
+ end
27
+
28
+ generate_method "#{reflection.name}_with_frozen_check" do |*args|
29
+ send(freezer).fetch(*args)
30
+ end
31
+ model_class.alias_method_chain reflection.name, :frozen_check
32
+
33
+ generate_method "#{reflection.name}_with_frozen_check=" do |*args|
34
+ if send(freezer).frozen?
35
+ # TODO make this a custom exception
36
+ raise "Unable to set #{reflection.name} because association is frozen."
37
+ else
38
+ send("#{reflection.name}_without_frozen_check=", *args)
39
+ end
40
+ end
41
+ model_class.alias_method_chain "#{reflection.name}=", :frozen_check
42
+ end
43
+
44
+ private
45
+
46
+ def previously_generated?
47
+ model_class.instance_methods.include? "freeze_#{@reflection.name}"
48
+ end
49
+
50
+ def frozen_column_exists?
51
+ model_class.column_names.include? "frozen_#{@reflection.name}"
52
+ end
53
+
54
+ def model_class
55
+ @reflection.active_record
56
+ end
57
+
58
+ def generate_method(name, &block)
59
+ model_class.send(:define_method, name, &block)
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ module AssociationFreezer
2
+ module ModelAdditions
3
+ # REFACTORME !!!
4
+ def enable_association_freezer
5
+ reflect_on_all_associations(:belongs_to).each do |reflection|
6
+ MethodGenerator.new(reflection).generate
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ class ActiveRecord::Base
13
+ extend AssociationFreezer::ModelAdditions
14
+ end
@@ -0,0 +1,4 @@
1
+ $:.unshift(File.dirname(__FILE__))
2
+ require 'association_freezer/model_additions'
3
+ require 'association_freezer/method_generator'
4
+ require 'association_freezer/belongs_to_freezer'
@@ -0,0 +1,106 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe Order do
4
+ it "respond to enable_association_freezer" do
5
+ Order.should respond_to(:enable_association_freezer)
6
+ end
7
+
8
+ describe "with freezer" do
9
+ before(:each) do
10
+ Order.enable_association_freezer
11
+ @order = Order.new
12
+ end
13
+
14
+ it "should add a freeze association method" do
15
+ @order.should respond_to(:freeze_ship_method)
16
+ end
17
+
18
+ it "should add an unfreeze association method" do
19
+ @order.should respond_to(:unfreeze_ship_method)
20
+ end
21
+
22
+ it "should not add freeze/unfreeze methods to cart association since it doesn't have a frozen column" do
23
+ @order.should_not respond_to(:freeze_cart)
24
+ @order.should_not respond_to(:unfreeze_cart)
25
+ end
26
+
27
+ it "should not be frozen at first" do
28
+ @order.ship_method = ShipMethod.create!
29
+ @order.ship_method(true).should_not be_frozen
30
+ end
31
+
32
+ it "should freeze association in its current state" do
33
+ ship_method = ShipMethod.create!(:price => 2)
34
+ @order.ship_method = ship_method
35
+ ship_method.price = 3
36
+ @order.freeze_ship_method
37
+ @order.ship_method.price.should == 3
38
+ end
39
+
40
+ it "should do nothing when attempting to freeze a nil association" do
41
+ @order.ship_method.should be_nil
42
+ lambda { @order.freeze_ship_method }.should_not raise_error
43
+ @order.ship_method.should be_nil
44
+ end
45
+
46
+ describe "when freezing association" do
47
+ before(:each) do
48
+ @ship_method = ShipMethod.create!(:price => 5)
49
+ @order.ship_method = @ship_method
50
+ @order.freeze_ship_method
51
+ end
52
+
53
+ it "should freeze associated model" do
54
+ @order.ship_method.should be_frozen
55
+ end
56
+
57
+ it "should not freeze original object" do
58
+ @ship_method.should_not be_frozen
59
+ end
60
+
61
+ it "should still consider model frozen after reloading association" do
62
+ @order.ship_method(true).should be_frozen
63
+ end
64
+
65
+ it "should ignore changes to associated model" do
66
+ @ship_method.update_attribute(:price, 8)
67
+ @order.ship_method.price.should == 5
68
+ @order.ship_method(true).price.should == 5
69
+ end
70
+
71
+ it "should be frozen after reloading order" do
72
+ @order.save!
73
+ @order.reload
74
+ @order.ship_method.should be_frozen
75
+ end
76
+
77
+ it "should not be frozen when unfreezing" do
78
+ @order.unfreeze_ship_method
79
+ @order.ship_method.should_not be_frozen
80
+ end
81
+
82
+ it "should restore original attributes when unfreezing" do
83
+ @ship_method.update_attribute(:price, 8)
84
+ @order.unfreeze_ship_method
85
+ @order.ship_method.price.should == 8
86
+ end
87
+
88
+ it "should raise an exception when attempting to save associated model" do
89
+ lambda { @order.ship_method.save }.should raise_error
90
+ lambda { @order.ship_method.save! }.should raise_error
91
+ end
92
+
93
+ it "should raise an exception when attempting to replace association" do
94
+ lambda { @order.ship_method = ShipMethod.new }.should raise_error
95
+ end
96
+
97
+ it "should keep id attribute for association" do
98
+ @order.ship_method.id.should == @ship_method.id
99
+ end
100
+
101
+ it "should not consider association a new record" do
102
+ @order.ship_method.should_not be_new_record
103
+ end
104
+ end
105
+ end
106
+ end
data/spec/lib/order.rb ADDED
@@ -0,0 +1,18 @@
1
+ class Order < ActiveRecord::Base
2
+ belongs_to :ship_method
3
+ belongs_to :cart
4
+ end
5
+
6
+ class CreateOrders < ActiveRecord::Migration
7
+ def self.up
8
+ create_table :orders do |t|
9
+ t.integer :ship_method_id
10
+ t.binary :frozen_ship_method
11
+ t.binary :cart_id
12
+ end
13
+ end
14
+
15
+ def self.down
16
+ drop_table :orders
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ class ShipMethod < ActiveRecord::Base
2
+ has_many :orders
3
+ end
4
+
5
+ class CreateShipMethods < ActiveRecord::Migration
6
+ def self.up
7
+ create_table :ship_methods do |t|
8
+ t.decimal :price
9
+ end
10
+ end
11
+
12
+ def self.down
13
+ drop_table :ship_methods
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'active_support'
4
+ require 'active_record'
5
+ require File.dirname(__FILE__) + '/../lib/association_freezer.rb'
6
+
7
+ # setup database adapter
8
+ FileUtils.mkdir_p(File.dirname(__FILE__) + "/../tmp/")
9
+ ActiveRecord::Base.establish_connection({
10
+ :adapter => "sqlite3",
11
+ :dbfile => File.dirname(__FILE__) + "/../tmp/test.sqlite3"
12
+ })
13
+
14
+ # load models
15
+ # there's probably a better way to handle this
16
+ require File.dirname(__FILE__) + '/lib/order.rb'
17
+ CreateOrders.migrate(:up) unless Order.table_exists?
18
+ require File.dirname(__FILE__) + '/lib/ship_method.rb'
19
+ CreateShipMethods.migrate(:up) unless ShipMethod.table_exists?
20
+
21
+ Spec::Runner.configure do |config|
22
+ config.mock_with :mocha
23
+ end
@@ -0,0 +1,2 @@
1
+ desc "Build the manifest and gemspec files."
2
+ task :build => [:build_manifest, :build_gemspec]
data/tasks/spec.rake ADDED
@@ -0,0 +1,9 @@
1
+ require 'spec/rake/spectask'
2
+
3
+ spec_files = Rake::FileList["spec/**/*_spec.rb"]
4
+
5
+ desc "Run specs"
6
+ Spec::Rake::SpecTask.new do |t|
7
+ t.spec_files = spec_files
8
+ t.spec_opts = ["-c"]
9
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ryanb-association-freezer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Bates
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-07-24 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Freeze an Active Record belongs_to association.
17
+ email: ryan (at) railscasts (dot) com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - CHANGELOG
24
+ - lib/association_freezer/belongs_to_freezer.rb
25
+ - lib/association_freezer/method_generator.rb
26
+ - lib/association_freezer/model_additions.rb
27
+ - lib/association_freezer.rb
28
+ - LICENSE
29
+ - README
30
+ - tasks/deployment.rake
31
+ - tasks/spec.rake
32
+ files:
33
+ - association-freezer.gemspec
34
+ - CHANGELOG
35
+ - lib/association_freezer/belongs_to_freezer.rb
36
+ - lib/association_freezer/method_generator.rb
37
+ - lib/association_freezer/model_additions.rb
38
+ - lib/association_freezer.rb
39
+ - LICENSE
40
+ - Manifest
41
+ - README
42
+ - spec/freezer_spec.rb
43
+ - spec/lib/order.rb
44
+ - spec/lib/ship_method.rb
45
+ - spec/spec_helper.rb
46
+ - tasks/deployment.rake
47
+ - tasks/spec.rake
48
+ - tmp/test.sqlite3
49
+ has_rdoc: true
50
+ homepage: http://github.com/ryanb/association-freezer
51
+ post_install_message:
52
+ rdoc_options:
53
+ - --line-numbers
54
+ - --inline-source
55
+ - --title
56
+ - Association-freezer
57
+ - --main
58
+ - README
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: "0"
66
+ version:
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: "0"
72
+ version:
73
+ requirements: []
74
+
75
+ rubyforge_project: association-freezer
76
+ rubygems_version: 1.2.0
77
+ signing_key:
78
+ specification_version: 2
79
+ summary: Freeze an Active Record belongs_to association.
80
+ test_files: []
81
+