Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Identity map #76

Merged
99 commits merged into from
@miloops

This is the implementation of Identity Map for ActiveRecord, Marcin Raczkowski's project for Ruby Summer Of Code (http://rubysoc.org/projects):

Project #12: ActiveRecord Identity Map

Our goal is provide plugable identity map implementation for ActiveRecord. An identity map is a design pattern used to improve performance by providing a in-memory cache to prevent duplicate retrieval of the same object data from the database, in our case in context of the same request or thread.

If the requested data has already been loaded from the database, the identity map returns the same instance of the already instantiated object, but if it has not been loaded yet, it loads it and stores the new object in the map. The main gains of this project will be performance improvement and memory consumption reduction.

@josevalim
Owner

Just one note, for those interested in trying it out, you need to add to your Gemfile:

gem "rails", :git => "git://github.com/miloops/rails.git", :branch => "identity_map"
gem "weakling", :git => "git://github.com/swistak/weakling.git"
gem "rack", :git => "git://github.com/rack/rack.git"
gem "arel", :git => "git://github.com/rails/arel.git"

@Soleone

Great stuff! This is very useful in large projects where each request has to load e.g. a "user" model or another context every time. We rolled our own customer Identity Map implementation in a very large app and definitely observed increased performance so I'm glad an official solution is in the works. Thanks!

@bensie

Awesome work!

@loe
loe commented

This is amazing! I think the best feature is being able to validate on both sides of an association without having to manually stitch them together in the controller.

@author.books.build

book validates_presence_of :author and author validates_presence_of :book

doing b = author.books.build; b.author = @author was frustrating at best!

@iain

Nice! I would love to write controller specs with mocking #save without having to mock .find:

site = Factory :site
site.should_receive(:update_attributes).with('foo' => 'bar').and_return(true)
put :update, :id => site.to_param, :site => { :foo => "bar" }
response.should redirect_to(site_url(site))

This actually works now!

Anyway, I've tried it on two real live Rails 3.0 apps and put them on rails master and miloops identimap_branch.

The first app showed no difference in performance. It has 332 examples, all 3 versions took around 20 seconds to run and around 118MB of RAM used. In the identity_map branch there was one failing spec.

A second project (544 specs) did show some differences between Rails 3.0 and the master branch, but no difference in performance in the identity_map branch. But there were a lot of failing specs though.

3.0 stable: 35 seconds, 272MB, 0 failing specs
master: 29 seconds, 260MB, 2 failing specs
identity_map: 29 seconds 260MB, 13 failing specs

These weren't real benchmarks or anything, I just ran rake spec and observed memory usage myself.
The failing specs were all different errors, but all related to updating and finding records.

Oh, I couldn't get cucumber to run on master or identity_map, which is a shame, because that would've been more representative of real usage.

@josevalim
Owner

Awesome feedback Iain! If you have some extra time, do you think you can give us more information about these extra errors you got?

@iain

It's past midnight here, so I'll be brief:

From the first app, a test that failed only in the identity_map branch:

site = Factory :site
other_site = Factory :site
recruiter = Factory :recruiter, :first_name => "before-update", :site_id => site.id
attrs = Factory.attributes_for(:recruiter, :first_name => "after-update", :site_id => other_site.id)
put :update, :id => recruiter.to_param, :recruiter => attrs
recruiter.reload.first_name.should == "after-update" # succeeds
recruiter.site.should == other_site # fails, still pointing to site, not other_site.

I can't really see what's wrong here and why the first_name field does update, but the site_id doesn't. Especially since it works in 3.0 and rails-master. It might be authentication/authorization that doesn't go quite well, because certain signed in users are not allowed to change the site_id. (I'm using devise, cancan and inherited_resources in this controller).

The other project has been around for a lot longer (was started with rails 3.0.0.beta, if I remember correctly) and has a lot more gem dependencies.

There were some errors I can understand that come from the identity map. I have these classes:

class User < ActiveRecord::Base
end
module Authentication
  class User < ::User
  end
end

And it sometimes picks the wrong one. This pattern sounded really cool when I first heard about it, but caused me nothing but headaches, but that's besides the point.

I got this one a couple of times:

put :update, :project_id => project.id, :id => comment.id, :comment => { :body => "" }, :format => :js
JSON.parse(response.body).should have_key('errors') # fails

And some that look like this:

user = Factory :user
comment1 = Factory :comment, :user => user
comment2 = Factory :comment, :user => user
subject.comments << comment1 << comment2
subject.save!
subject.reload.comments.should == [ comment1, comment2 ]

Where I get just one comment instead of both. But when I removed the call to reload it worked again.
It works without the reload in 3.0 too, and I'm not entirely sure why I put it there in the first place. I guess putting reload in is one of the first things I try to do when debugging something.

I guess the majority of failing specs fail because they happened to be accidentally passing before. I found a couple instances where I was testing the wrong object. So I think this update will be a huge improvement and help you find bugs faster than before.

Edit: well, that didn't turn out to be very brief at all! :)

@miloops

Hey Iain, you should try it now, in the latests commits i added a middleware to flush identity map on each requests, flush IM on tests and many other things that you can check out in today's commits.

Feel free to add me on IM miloops at gmail in case to discuss any problem you are having.

@iain

It seems to work fine when running the the server, but I still have some issues running my specs. Like this one:

  subject { Factory.build :profile, :first_name => "Jan" }
  it "accepts utf8" do
    subject.first_name = "☃"
    subject.save!
    subject.reload.first_name.should == "☃"
  end

When I run rake spec:models, or rspec spec/models/profile_spec.rb, it works passes.
When I run rake spec it fails. Weirdly enough, it returns the default value from the factory, even though I never mention that in my specs:

  6) Profile accepts utf8
     Failure/Error: subject.reload.first_name.should == "☃"
     expected: "\342\230\203",
          got: "Kees" (using ==)
     # ./spec/models/profile_spec.rb:34

I don't have any time anymore tonight, but I'll be happy to discuss it with you soon.

@josevalim
Owner

Iain, you are using rspec, so a callback that we added to ActiveSupport::TestCase is not being executed. Please try adding the code below (it should run before each test in the whole suite):

before(:each) do
  ActiveRecord::IdentityMap.clear
end

It will likely solve the issue. :)

@iain

I found one bug in rails master (not specific to identity map):

>> Project.select(:id).map(&:id)
  Project Load (2.6ms)  SELECT 'id' FROM `projects`
=> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, etc...
>> Project.select('id').map(&:id)
  Project Load (0.7ms)  SELECT id FROM `projects`
=> [7, 16, 76, 92, 98, 101, 102, 116, etc....

On a more related note, the only other issue I could find was that ActiveRecord::IdentityMap.clear doesn't clear the aggregation cache. I'm not sure whether it should, but it is something that broke my controller spec:

it "shows errors for invalid comment when html" do
  comment.clear_aggregation_cache
  put :update, :project_id => project.id, :id => comment.id, :comment => { :body => "" }
  assigns(:comment).should_not be_valid # fails without the clear_aggregation_cache 2 lines up
  response.should render_template(:edit)
end

I couldn't find anything else.

swistak and others added some commits
@swistak swistak IdentityMap - Adding Weakling and IM Base as concern 3df4460
@swistak swistak IdentityMap - Tests for IM ce3ea55
@swistak swistak IdentityMap - misc fixes
- Added IdentityMap to be included into AR::Base
- Fixed bug with Mysql namespace missing when running tests only for sqlite
- Added sqlite as default connection
ce66bfd
@swistak swistak IdentityMap - adding and removing of records on create/update c357f84
@swistak swistak Looping prevention for autosave relations on validation and creation 3ab625f
@swistak swistak IdentityMap - Adjustments to test cases ccb335d
@swistak swistak IdentityMap - Fixes problem with dirty attributes eebf33a
@miloops miloops Remove object from identity map if transaction failed. e88fd02
@miloops miloops Use identity mapper only if enabled. 09f12a1
@miloops miloops Remove associated records from identity map if any raised an unexpect…
…ed exception.
7df6175
@miloops miloops Prevent pushing duplicated records when using identity map. dd6e680
@miloops miloops Disable identity map when loading associated records from habtm. fa11c60
@miloops miloops Use yield instead block argument. f0eaf11
@miloops miloops Uncomment test and make it work. f1913ad
@miloops miloops Add test to show that when IdentityMap is disabled finders returns di…
…fferent objects.
a9edd6c
@miloops miloops Remove objects from identity map if save failed, otherwise finding ag…
…ain the same record will have invalid attributes.
e83f5a0
@miloops miloops Remove objects from identity map if save! failed, otherwise finding a…
…gain the same record will have invalid attributes.
f3adddb
@miloops miloops Add test for update_attributes and identity map. 6b0b95f
@miloops miloops Associated objects are assigned from identity map if enabled and cont…
…ains associated object.
234bbe5
@miloops miloops Remove associated object from identity map when reloading. 6f68447
@miloops miloops Add tests for inverse relations when using has many and identity map. 448420f
@miloops miloops Don't use identity map if loading readonly records, this will prevent…
… changing readonly status on already loaded records.
c0ad5e4
@miloops miloops implicit_readonly is not set until records are loaded, just check rea…
…donly_value and then set readonly status.
a3210d9
@miloops miloops Use strings primary keys in identity map keys to avoid problems with …
…casting and also allow strings primary keys.
0f16949
@miloops miloops Change test models. cd6d6fc
@miloops miloops Testing objects equality is what we are looking for here, no query ca…
…ching.
21483cb
@miloops miloops Test with target object, failing on 1.9.2 when comparing object again…
…st association proxy object.
ab42382
@miloops miloops Set Identity Map disabled by default. Enable it for testing. 6d58b27
@swistak swistak Separated initialization 24485b9
@swistak swistak Test reorganization 7404505
@swistak swistak Reeject attributes even if association is loaded 76f33dc
@miloops miloops Fix test name and typo. 003e4fb
@miloops miloops Added config syntax to enable/disable identity map: config.active_rec…
…ord.identity_map = true
da721da
@miloops miloops Add docs to Identity Map. a62f722
@miloops miloops IdentityMap is enabled by default. 4f3b8e1
@miloops miloops Use hash[:Post][1] style identity maps for each table. 301dd3d
@miloops miloops Use ActiveSupport::WeakHash for MRI, JRuby prefers Weakling. ada0149
@miloops miloops Weakling is only required for JRuby. 8669933
@miloops miloops Use conditional to avoid warnings. 0873d1e
@miloops miloops Check if constant is defined in AR, if not this can cause errors when…
… using polymorphic associations.
96cc08f
@miloops miloops Add initial tests for WeakHash. 4da31d2
@miloops miloops Use IdentityMap middleware to flush map on each request. f3722a3
@miloops miloops Use association_class method which returns the reflection class, this…
… method is redefined in polymorphic belongs to associations.
c036caf
@miloops miloops Flush IdentityMap when running tests. 87aa913
@miloops miloops Use just one repository and keep it in the current thread. d13df4c
@miloops miloops Change API name, we don't need any param. 3a34ae0
@miloops miloops Use block syntax in IdentityMap middleware. 08c37b7
@miloops miloops Bring back "Reject attributes even if association is loaded" after re…
…basing to master.
333cdcd
@miloops miloops Don't wrap into identity map if it is disabled. 48edab9
@miloops miloops Don't load IdentityMap middleware if not enabled. Simplify middleware. 69b627e
@miloops miloops Revert "IdentityMap - Adjustments to test cases"
This reverts commit 4db9dca55e3acc2c59f252eb83ecb83db5f4b81b.

Conflicts:

	activerecord/test/cases/identity_map_test.rb
93daf1b
@miloops miloops Ups, forgot to remove one conflict tag from previous commit. 9fe0dbf
@miloops miloops Fix number of queries performed in tests. 4015819
@miloops miloops Call super setup in this test. edb69b9
@miloops miloops Clear IdentityMap before continue this test, we can do this here beca…
…use store_full_sti_class is not supposed to change during "runtime".
4a0a160
@miloops miloops Added method to IM to remove objects by class and id. Then used it to…
… remove objects when updating counters.
024bc70
@miloops miloops Query objects if readonly_value is false, skip them only if nil. b9e869a
@miloops miloops Test setup method should clean up IM. 095c445
@miloops miloops Remove associated objects from IM when clearing them from association…
… cache.
5ee3663
@miloops miloops Don't change tests, fix code: if locking is enabled skip IM. 7892543
@miloops miloops Update number of queries executed instead of avoiding IM. 6cd1224
@miloops miloops Refactor associations cache removal from IM. (ht: Aaron Patterson) 7d563f9
@miloops miloops We don't need to dup key, since only value is weak. a84add0
@miloops miloops Set IdentityMap disabled by default. 938f168
@miloops miloops Read from config, because AR may not be loaded yet. 421643b
@miloops miloops Revert "Use ActiveSupport::WeakHash for MRI, JRuby prefers Weakling."
This reverts commit 3cddebc2402eb71f2806e8b2119dc3efdceb4662.

Conflicts:

	activerecord/lib/active_record/identity_map.rb
	activesupport/lib/active_support/weak_hash.rb
2fd48c8
@miloops miloops Usa Hash instead of WeakHash. dd166fa
@miloops miloops No need to check returned object now that weakhash is gone. 22696b5
@miloops miloops Add test using identity map and select. 1c88d59
@miloops miloops IM is disabled by default. b626f3e
@miloops miloops Enable IM in performance script unless IM=disabled is set when runnin…
…g it.
54f924c
@miloops miloops We have to check object class to avoid issues when using STI. 1d530e2
@miloops miloops Fix typo. d472241
@miloops miloops Enable IdentityMap when generating new apps. 375aaa9
@miloops miloops Simplify remove_from_config. 5098302
@miloops miloops identity_map name is used for configuration, use IdentityMap to acces…
…s it.
72aa6f4
@miloops miloops "there is no need to store this option just for initialization" José …
…Valim dixit.
a12bb71
@miloops miloops IM enable should be kept in current thread. d9c0340
@miloops miloops No need to specify clear is a method from IM when we are inside IM. b2b5d02
@miloops miloops Clean IdentityMap before running each benchmark. 2ba06b4
@miloops miloops Merge remote branch 'rails/master' into identity_map
Conflicts:
	activerecord/lib/active_record/associations/association_proxy.rb
	activerecord/lib/active_record/autosave_association.rb
	activerecord/lib/active_record/base.rb
	activerecord/lib/active_record/persistence.rb
02fc6fb
@miloops miloops Should save without validation if autosave is enabled. 348c0ec
@alexbartlow

Jose,

How does this mesh with Rack::FiberPool and EventMachine-based DB adapters that run every request in its own fiber?

Thanks for your work on this,

-Alex

miloops added some commits
@miloops miloops Merge remote branch 'rails/master' into identity_map
Conflicts:
	activerecord/examples/performance.rb
	activerecord/lib/active_record/association_preload.rb
	activerecord/lib/active_record/associations.rb
	activerecord/lib/active_record/associations/association_proxy.rb
	activerecord/lib/active_record/autosave_association.rb
	activerecord/lib/active_record/base.rb
	activerecord/lib/active_record/nested_attributes.rb
	activerecord/test/cases/relations_test.rb
8ee0b44
@miloops miloops Remove identity map from benchmark script. ca75091
@miloops miloops Run tests without IdentityMap when IM=false is given. f1778eb
@miloops miloops Don't shadow outer local variable. c13b7c4
@miloops miloops Fix expected queries in relation tests. 90a850a
@miloops miloops Merge remote branch 'rails/master' into identity_map
Conflicts:
	activerecord/lib/active_record/associations/association.rb
	activerecord/lib/active_record/fixtures.rb
0b702ba
@miloops miloops No need to test against target anymore. 15a03ca
@miloops miloops Use to_a instead :load in test, since :load changed. b8c2feb
@miloops miloops Reindent and remove wrong line left in merge by mistake. 3560869
@miloops miloops No need to test agaisnt target. d21a454
@miloops miloops Should use "=" instead "replace" after this commit: 1644663 eb23b22
@miloops miloops Initialize @target instead asking if it is defined. 49f3525
@miloops miloops No need to have reinit_with inside an InstanceMethods module. 8052623
@miloops miloops WeakHash is not used, remove it. d9eb007
@miloops miloops Remove nbproject form gitignore. This shouldn't be here in the first …
…place.
3e5efb3
@miloops miloops Don't use skip, just don't run anything, we don't have skip in Ruby 1.8 3927827
@miloops miloops Merge remote branch 'rails/master' into identity_map 00418ac
@hamin

AWESOME!

@pechorin

very cool! i am waiting for release in stable

@jweiss

Why is this tied to ActiveRecord and not an ActiveModel functionality?
I wanted to add support for SimplyStored (CouchDB wrapper) but it seems wrong to require ActiveRecord...

@josevalim
Owner

I believe the part of IdentityMap that is agnostic is actually quite small. Most of concerns are actually in cleaning up the identity map and identifying all the situations that require so. If you think there is a significant part of the identity map that could be moved to ActiveModel, please do provide a patch!

@jweiss

I'm taking about https://github.com/rails/rails/blob/master/activerecord/lib/active_record/identity_map.rb

This looks totaly generic to me and could be copied for my SimplyStored IdentityMap. I'll see that I extract it.

This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Nov 19, 2010
  1. @swistak @miloops

    IdentityMap - Adding Weakling and IM Base as concern

    swistak authored miloops committed
  2. @swistak @miloops

    IdentityMap - Tests for IM

    swistak authored miloops committed
  3. @swistak @miloops

    IdentityMap - misc fixes

    swistak authored miloops committed
    - Added IdentityMap to be included into AR::Base
    - Fixed bug with Mysql namespace missing when running tests only for sqlite
    - Added sqlite as default connection
  4. @swistak @miloops
  5. @swistak @miloops
  6. @swistak @miloops

    IdentityMap - Adjustments to test cases

    swistak authored miloops committed
  7. @swistak @miloops

    IdentityMap - Fixes problem with dirty attributes

    swistak authored miloops committed
  8. @miloops
  9. @miloops
  10. @miloops
  11. @miloops
  12. @miloops
  13. @miloops
  14. @miloops
  15. @miloops
  16. @miloops

    Remove objects from identity map if save failed, otherwise finding ag…

    miloops authored
    …ain the same record will have invalid attributes.
  17. @miloops

    Remove objects from identity map if save! failed, otherwise finding a…

    miloops authored
    …gain the same record will have invalid attributes.
  18. @miloops
  19. @miloops
  20. @miloops
  21. @miloops
  22. @miloops

    Don't use identity map if loading readonly records, this will prevent…

    miloops authored
    … changing readonly status on already loaded records.
  23. @miloops

    implicit_readonly is not set until records are loaded, just check rea…

    miloops authored
    …donly_value and then set readonly status.
  24. @miloops

    Use strings primary keys in identity map keys to avoid problems with …

    miloops authored
    …casting and also allow strings primary keys.
  25. @miloops

    Change test models.

    miloops authored
  26. @miloops
  27. @miloops

    Test with target object, failing on 1.9.2 when comparing object again…

    miloops authored
    …st association proxy object.
  28. @miloops
  29. @swistak @miloops

    Separated initialization

    swistak authored miloops committed
  30. @swistak @miloops

    Test reorganization

    swistak authored miloops committed
  31. @swistak @miloops

    Reeject attributes even if association is loaded

    swistak authored miloops committed
  32. @miloops

    Fix test name and typo.

    miloops authored
  33. @miloops
  34. @miloops

    Add docs to Identity Map.

    miloops authored
  35. @miloops
  36. @miloops
  37. @miloops
  38. @miloops
  39. @miloops
  40. @miloops

    Check if constant is defined in AR, if not this can cause errors when…

    miloops authored
    … using polymorphic associations.
  41. @miloops
  42. @miloops
  43. @miloops

    Use association_class method which returns the reflection class, this…

    miloops authored
    … method is redefined in polymorphic belongs to associations.
  44. @miloops
  45. @miloops
  46. @miloops
  47. @miloops
  48. @miloops
  49. @miloops
  50. @miloops
  51. @miloops

    Revert "IdentityMap - Adjustments to test cases"

    miloops authored
    This reverts commit 4db9dca55e3acc2c59f252eb83ecb83db5f4b81b.
    
    Conflicts:
    
    	activerecord/test/cases/identity_map_test.rb
  52. @miloops
  53. @miloops
  54. @miloops

    Call super setup in this test.

    miloops authored
  55. @miloops

    Clear IdentityMap before continue this test, we can do this here beca…

    miloops authored
    …use store_full_sti_class is not supposed to change during "runtime".
  56. @miloops

    Added method to IM to remove objects by class and id. Then used it to…

    miloops authored
    … remove objects when updating counters.
  57. @miloops
  58. @miloops
  59. @miloops
  60. @miloops
  61. @miloops
  62. @miloops
  63. @miloops
  64. @miloops
  65. @miloops
  66. @miloops

    Revert "Use ActiveSupport::WeakHash for MRI, JRuby prefers Weakling."

    miloops authored
    This reverts commit 3cddebc2402eb71f2806e8b2119dc3efdceb4662.
    
    Conflicts:
    
    	activerecord/lib/active_record/identity_map.rb
    	activesupport/lib/active_support/weak_hash.rb
  67. @miloops

    Usa Hash instead of WeakHash.

    miloops authored
  68. @miloops
  69. @miloops
  70. @miloops

    IM is disabled by default.

    miloops authored
  71. @miloops
  72. @miloops
  73. @miloops

    Fix typo.

    miloops authored
  74. @miloops
  75. @miloops

    Simplify remove_from_config.

    miloops authored
  76. @miloops
  77. @miloops
  78. @miloops
  79. @miloops
  80. @miloops
Commits on Dec 20, 2010
  1. @miloops

    Merge remote branch 'rails/master' into identity_map

    miloops authored
    Conflicts:
    	activerecord/lib/active_record/associations/association_proxy.rb
    	activerecord/lib/active_record/autosave_association.rb
    	activerecord/lib/active_record/base.rb
    	activerecord/lib/active_record/persistence.rb
  2. @miloops
Commits on Feb 15, 2011
  1. @miloops

    Merge remote branch 'rails/master' into identity_map

    miloops authored
    Conflicts:
    	activerecord/examples/performance.rb
    	activerecord/lib/active_record/association_preload.rb
    	activerecord/lib/active_record/associations.rb
    	activerecord/lib/active_record/associations/association_proxy.rb
    	activerecord/lib/active_record/autosave_association.rb
    	activerecord/lib/active_record/base.rb
    	activerecord/lib/active_record/nested_attributes.rb
    	activerecord/test/cases/relations_test.rb
  2. @miloops
  3. @miloops
  4. @miloops
  5. @miloops
Commits on Feb 18, 2011
  1. @miloops

    Merge remote branch 'rails/master' into identity_map

    miloops authored
    Conflicts:
    	activerecord/lib/active_record/associations/association.rb
    	activerecord/lib/active_record/fixtures.rb
  2. @miloops
  3. @miloops
  4. @miloops
  5. @miloops
  6. @miloops
  7. @miloops
  8. @miloops
  9. @miloops
  10. @miloops
  11. @miloops
  12. @miloops
This page is out of date. Refresh to see the latest.
Showing with 833 additions and 40 deletions.
  1. +1 −0  activerecord/lib/active_record.rb
  2. +17 −1 activerecord/lib/active_record/associations/association.rb
  3. +2 −2 activerecord/lib/active_record/associations/class_methods/join_dependency.rb
  4. +5 −0 activerecord/lib/active_record/attribute_methods/dirty.rb
  5. +26 −3 activerecord/lib/active_record/autosave_association.rb
  6. +26 −4 activerecord/lib/active_record/base.rb
  7. +2 −0  activerecord/lib/active_record/counter_cache.rb
  8. +3 −1 activerecord/lib/active_record/fixtures.rb
  9. +102 −0 activerecord/lib/active_record/identity_map.rb
  10. +10 −3 activerecord/lib/active_record/nested_attributes.rb
  11. +12 −2 activerecord/lib/active_record/persistence.rb
  12. +5 −0 activerecord/lib/active_record/railtie.rb
  13. +7 −1 activerecord/lib/active_record/relation.rb
  14. +10 −0 activerecord/lib/active_record/test_case.rb
  15. +1 −0  activerecord/lib/active_record/transactions.rb
  16. +1 −1  activerecord/test/cases/adapters/mysql/connection_test.rb
  17. +1 −0  activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb
  18. +7 −7 activerecord/test/cases/associations/eager_test.rb
  19. +2 −2 activerecord/test/cases/associations/has_one_through_associations_test.rb
  20. +137 −0 activerecord/test/cases/associations/identity_map_test.rb
  21. +1 −1  activerecord/test/cases/associations/join_model_test.rb
  22. +3 −2 activerecord/test/cases/autosave_association_test.rb
  23. +11 −1 activerecord/test/cases/helper.rb
  24. +402 −0 activerecord/test/cases/identity_map_test.rb
  25. +9 −7 activerecord/test/cases/relations_test.rb
  26. +4 −0 activerecord/test/fixtures/subscribers.yml
  27. +5 −0 railties/lib/rails/generators/rails/app/templates/config/application.rb
  28. +4 −0 railties/lib/rails/test_help.rb
  29. +2 −2 railties/test/application/initializers/frameworks_test.rb
  30. +7 −0 railties/test/application/middleware_test.rb
  31. +8 −0 railties/test/isolation/abstract_unit.rb
View
1  activerecord/lib/active_record.rb
@@ -79,6 +79,7 @@ module ActiveRecord
autoload :Timestamp
autoload :Transactions
autoload :Validations
+ autoload :IdentityMap
end
module Coders
View
18 activerecord/lib/active_record/associations/association.rb
@@ -24,6 +24,7 @@ class Association #:nodoc:
def initialize(owner, reflection)
reflection.check_validity!
+ @target = nil
@owner, @reflection = owner, reflection
@updated = false
@@ -42,6 +43,7 @@ def aliased_table_name
# Resets the \loaded flag to +false+ and sets the \target to +nil+.
def reset
@loaded = false
+ IdentityMap.remove(@target) if IdentityMap.enabled? && @target
@target = nil
end
@@ -141,7 +143,17 @@ def target_scope
# ActiveRecord::RecordNotFound is rescued within the method, and it is
# not reraised. The proxy is \reset and +nil+ is the return value.
def load_target
- @target = find_target if find_target?
+ if find_target?
+ begin
+ if IdentityMap.enabled? && association_class && association_class.respond_to?(:base_class)
+ @target = IdentityMap.get(association_class, @owner[@reflection.foreign_key])
+ end
+ rescue NameError
+ nil
+ ensure
+ @target ||= find_target
+ end
+ end
loaded!
target
rescue ActiveRecord::RecordNotFound
@@ -241,6 +253,10 @@ def invertible_for?(record)
# This is only relevant to certain associations, which is why it returns nil by default.
def stale_state
end
+
+ def association_class
+ @reflection.klass
+ end
end
end
end
View
4 activerecord/lib/active_record/associations/class_methods/join_dependency.rb
@@ -187,8 +187,8 @@ def construct(parent, associations, join_parts, row)
construct(parent, association, join_parts, row)
end
when Hash
- associations.sort_by { |k,_| k.to_s }.each do |name, assoc|
- association = construct(parent, name, join_parts, row)
+ associations.sort_by { |k,_| k.to_s }.each do |association_name, assoc|
+ association = construct(parent, association_name, join_parts, row)
construct(association, assoc, join_parts, row) if association
end
else
View
5 activerecord/lib/active_record/attribute_methods/dirty.rb
@@ -22,6 +22,8 @@ def save(*) #:nodoc:
if status = super
@previously_changed = changes
@changed_attributes.clear
+ elsif IdentityMap.enabled?
+ IdentityMap.remove(self)
end
status
end
@@ -32,6 +34,9 @@ def save!(*) #:nodoc:
@previously_changed = changes
@changed_attributes.clear
end
+ rescue
+ IdentityMap.remove(self) if IdentityMap.enabled?
+ raise
end
# <tt>reload</tt> the record and clears changed attributes.
View
29 activerecord/lib/active_record/autosave_association.rb
@@ -140,6 +140,23 @@ def #{type}(name, options = {})
CODE
end
+ def define_non_cyclic_method(name, reflection, &block)
+ define_method(name) do |*args|
+ result = true; @_already_called ||= {}
+ # Loop prevention for validation of associations
+ unless @_already_called[[name, reflection.name]]
+ begin
+ @_already_called[[name, reflection.name]]=true
+ result = instance_eval(&block)
+ ensure
+ @_already_called[[name, reflection.name]]=false
+ end
+ end
+
+ result
+ end
+ end
+
# Adds validation and save callbacks for the association as specified by
# the +reflection+.
#
@@ -160,7 +177,7 @@ def add_autosave_association_callbacks(reflection)
if collection
before_save :before_save_collection_association
- define_method(save_method) { save_collection_association(reflection) }
+ define_non_cyclic_method(save_method, reflection) { save_collection_association(reflection) }
# Doesn't use after_save as that would save associations added in after_create/after_update twice
after_create save_method
after_update save_method
@@ -178,7 +195,7 @@ def add_autosave_association_callbacks(reflection)
after_create save_method
after_update save_method
else
- define_method(save_method) { save_belongs_to_association(reflection) }
+ define_non_cyclic_method(save_method, reflection) { save_belongs_to_association(reflection) }
before_save save_method
end
end
@@ -186,7 +203,7 @@ def add_autosave_association_callbacks(reflection)
if reflection.validate? && !method_defined?(validation_method)
method = (collection ? :validate_collection_association : :validate_single_association)
- define_method(validation_method) { send(method, reflection) }
+ define_non_cyclic_method(validation_method, reflection) { send(method, reflection) }
validate validation_method
end
end
@@ -303,6 +320,7 @@ def save_collection_association(reflection)
autosave = reflection.options[:autosave]
if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave)
+ begin
records.each do |record|
next if record.destroyed?
@@ -322,6 +340,11 @@ def save_collection_association(reflection)
raise ActiveRecord::Rollback unless saved
end
+ rescue
+ records.each {|x| IdentityMap.remove(x) } if IdentityMap.enabled?
+ raise
+ end
+
end
# reconstruct the scope now that we know the owner's id
View
30 activerecord/lib/active_record/base.rb
@@ -819,6 +819,10 @@ def ===(object)
object.is_a?(self)
end
+ def symbolized_base_class
+ @symbolized_base_class ||= base_class.to_s.to_sym
+ end
+
# Returns the base AR subclass that this class descends from. If A
# extends AR::Base, A.base_class will return A. If B descends from A
# through some arbitrarily deep hierarchy, B.base_class will return A.
@@ -913,10 +917,25 @@ def _load(data)
# Finder methods must instantiate through this method to work with the
# single-table inheritance model that makes it possible to create
# objects of different types from the same table.
- def instantiate(record) # :nodoc:
- model = find_sti_class(record[inheritance_column]).allocate
- model.init_with('attributes' => record)
- model
+ def instantiate(record)
+ sti_class = find_sti_class(record[inheritance_column])
+ record_id = sti_class.primary_key && record[sti_class.primary_key]
+
+ if ActiveRecord::IdentityMap.enabled? && record_id
+ if (column = sti_class.columns_hash[sti_class.primary_key]) && column.number?
+ record_id = record_id.to_i
+ end
+ if instance = IdentityMap.get(sti_class, record_id)
+ instance.reinit_with('attributes' => record)
+ else
+ instance = sti_class.allocate.init_with('attributes' => record)
+ IdentityMap.add(instance)
+ end
+ else
+ instance = sti_class.allocate.init_with('attributes' => record)
+ end
+
+ instance
end
private
@@ -1467,6 +1486,8 @@ def init_with(coder)
@new_record = false
run_callbacks :find
run_callbacks :initialize
+
+ self
end
# Specifies how the record is dumped by +Marshal+.
@@ -1933,6 +1954,7 @@ def clear_timestamp_attributes
include ActiveModel::MassAssignmentSecurity
include Callbacks, ActiveModel::Observing, Timestamp
include Associations, AssociationPreload, NamedScope
+ include IdentityMap
include ActiveModel::SecurePassword
# AutosaveAssociation needs to be included before Transactions, because we want
View
2  activerecord/lib/active_record/counter_cache.rb
@@ -74,6 +74,8 @@ def update_counters(id, counters)
"#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{value.abs}"
end
+ IdentityMap.remove_by_id(symbolized_base_class, id) if IdentityMap.enabled?
+
update_all(updates.join(', '), primary_key => id )
end
View
4 activerecord/lib/active_record/fixtures.rb
@@ -887,7 +887,9 @@ def setup_fixture_accessors(fixture_names = nil)
@fixture_cache[fixture_name].delete(fixture) if force_reload
if @loaded_fixtures[fixture_name][fixture.to_s]
- @fixture_cache[fixture_name][fixture] ||= @loaded_fixtures[fixture_name][fixture.to_s].find
+ ActiveRecord::IdentityMap.without do
+ @fixture_cache[fixture_name][fixture] ||= @loaded_fixtures[fixture_name][fixture.to_s].find
+ end
else
raise StandardError, "No fixture with name '#{fixture}' found for table '#{fixture_name}'"
end
View
102 activerecord/lib/active_record/identity_map.rb
@@ -0,0 +1,102 @@
+module ActiveRecord
+ # = Active Record Identity Map
+ #
+ # Ensures that each object gets loaded only once by keeping every loaded
+ # object in a map. Looks up objects using the map when referring to them.
+ #
+ # More information on Identity Map pattern:
+ # http://www.martinfowler.com/eaaCatalog/identityMap.html
+ #
+ # == Configuration
+ #
+ # In order to enable IdentityMap, set <tt>config.active_record.identity_map = true</tt>
+ # in your <tt>config/application.rb</tt> file.
+ #
+ # IdentityMap is disabled by default.
+ #
+ module IdentityMap
+ extend ActiveSupport::Concern
+
+ class << self
+ def enabled=(flag)
+ Thread.current[:identity_map_enabled] = flag
+ end
+
+ def enabled
+ Thread.current[:identity_map_enabled]
+ end
+ alias enabled? enabled
+
+ def repository
+ Thread.current[:identity_map] ||= Hash.new { |h,k| h[k] = {} }
+ end
+
+ def use
+ old, self.enabled = enabled, true
+
+ yield if block_given?
+ ensure
+ self.enabled = old
+ clear
+ end
+
+ def without
+ old, self.enabled = enabled, false
+
+ yield if block_given?
+ ensure
+ self.enabled = old
+ end
+
+ def get(klass, primary_key)
+ obj = repository[klass.symbolized_base_class][primary_key]
+ obj.is_a?(klass) ? obj : nil
+ end
+
+ def add(record)
+ repository[record.class.symbolized_base_class][record.id] = record
+ end
+
+ def remove(record)
+ repository[record.class.symbolized_base_class].delete(record.id)
+ end
+
+ def remove_by_id(symbolized_base_class, id)
+ repository[symbolized_base_class].delete(id)
+ end
+
+ def clear
+ repository.clear
+ end
+ end
+
+ # Reinitialize an Identity Map model object from +coder+.
+ # +coder+ must contain the attributes necessary for initializing an empty
+ # model object.
+ def reinit_with(coder)
+ @attributes_cache = {}
+ dirty = @changed_attributes.keys
+ @attributes.update(coder['attributes'].except(*dirty))
+ @changed_attributes.update(coder['attributes'].slice(*dirty))
+ @changed_attributes.delete_if{|k,v| v.eql? @attributes[k]}
+
+ set_serialized_attributes
+
+ run_callbacks :find
+
+ self
+ end
+
+ class Middleware
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ ActiveRecord::IdentityMap.use do
+ @app.call(env)
+ end
+ end
+ end
+ end
+end
View
13 activerecord/lib/active_record/nested_attributes.rb
@@ -403,7 +403,12 @@ def assign_nested_attributes_for_collection_association(association_name, attrib
unless reject_new_record?(association_name, attributes)
association.build(attributes.except(*UNASSIGNABLE_KEYS))
end
-
+ elsif existing_records.count == 0 #Existing record but not yet associated
+ existing_record = self.class.reflect_on_association(association_name).klass.find(attributes['id'])
+ if !call_reject_if(association_name, attributes)
+ association.send(:add_record_to_target_with_callbacks, existing_record) if !association.loaded?
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
+ end
elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes['id'].to_s }
unless association.loaded? || call_reject_if(association_name, attributes)
# Make sure we are operating on the actual object which is in the association's
@@ -415,10 +420,12 @@ def assign_nested_attributes_for_collection_association(association_name, attrib
else
association.add_to_target(existing_record)
end
- end
- assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
+ end
+ if !call_reject_if(association_name, attributes)
+ assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
+ end
else
raise_nested_attributes_record_not_found(association_name, attributes['id'])
end
View
14 activerecord/lib/active_record/persistence.rb
@@ -64,7 +64,10 @@ def save!(*)
# callbacks, Observer methods, or any <tt>:dependent</tt> association
# options, use <tt>#destroy</tt>.
def delete
- self.class.delete(id) if persisted?
+ if persisted?
+ self.class.delete(id)
+ IdentityMap.remove(self) if IdentityMap.enabled?
+ end
@destroyed = true
freeze
end
@@ -73,6 +76,7 @@ def delete
# that no changes should be made (since they can't be persisted).
def destroy
if persisted?
+ IdentityMap.remove(self) if IdentityMap.enabled?
self.class.unscoped.where(self.class.arel_table[self.class.primary_key].eq(id)).delete_all
end
@@ -196,7 +200,12 @@ def toggle!(attribute)
def reload(options = nil)
clear_aggregation_cache
clear_association_cache
- @attributes.update(self.class.unscoped { self.class.find(self.id, options) }.instance_variable_get('@attributes'))
+
+ IdentityMap.without do
+ fresh_object = self.class.unscoped { self.class.find(self.id, options) }
+ @attributes.update(fresh_object.instance_variable_get('@attributes'))
+ end
+
@attributes_cache = {}
self
end
@@ -275,6 +284,7 @@ def create
self.id ||= new_id
+ IdentityMap.add(self) if IdentityMap.enabled?
@new_record = false
id
end
View
5 activerecord/lib/active_record/railtie.rb
@@ -43,6 +43,11 @@ class Railtie < Rails::Railtie
ActiveSupport.on_load(:active_record) { self.logger ||= ::Rails.logger }
end
+ initializer "active_record.identity_map" do |app|
+ config.app_middleware.insert_after "::ActionDispatch::Callbacks",
+ "ActiveRecord::IdentityMap::Middleware" if config.active_record.delete(:identity_map)
+ end
+
initializer "active_record.set_configs" do |app|
ActiveSupport.on_load(:active_record) do
app.config.active_record.each do |k,v|
View
8 activerecord/lib/active_record/relation.rb
@@ -81,7 +81,13 @@ def respond_to?(method, include_private = false)
def to_a
return @records if loaded?
- @records = eager_loading? ? find_with_associations : @klass.find_by_sql(arel.to_sql, @bind_values)
+ @records = if @readonly_value.nil? && !@klass.locking_enabled?
+ eager_loading? ? find_with_associations : @klass.find_by_sql(arel.to_sql, @bind_values)
+ else
+ IdentityMap.without do
+ eager_loading? ? find_with_associations : @klass.find_by_sql(arel.to_sql, @bind_values)
+ end
+ end
preload = @preload_values
preload += @includes_values unless eager_loading?
View
10 activerecord/lib/active_record/test_case.rb
@@ -3,6 +3,16 @@ module ActiveRecord
#
# Defines some test assertions to test against SQL queries.
class TestCase < ActiveSupport::TestCase #:nodoc:
+ setup :cleanup_identity_map
+
+ def setup
+ cleanup_identity_map
+ end
+
+ def cleanup_identity_map
+ ActiveRecord::IdentityMap.clear
+ end
+
def assert_date_from_db(expected, actual, message = nil)
# SybaseAdapter doesn't have a separate column type just for dates,
# so the time is in the string and incorrectly formatted
View
1  activerecord/lib/active_record/transactions.rb
@@ -251,6 +251,7 @@ def rollback_active_record_state!
remember_transaction_record_state
yield
rescue Exception
+ IdentityMap.remove(self) if IdentityMap.enabled?
restore_transaction_record_state
raise
ensure
View
2  activerecord/test/cases/adapters/mysql/connection_test.rb
@@ -102,7 +102,7 @@ def test_exec_typecasts_bind_vals
end
# Test that MySQL allows multiple results for stored procedures
- if Mysql.const_defined?(:CLIENT_MULTI_RESULTS)
+ if defined?(Mysql) && Mysql.const_defined?(:CLIENT_MULTI_RESULTS)
def test_multi_results
rows = ActiveRecord::Base.connection.select_rows('CALL ten();')
assert_equal 10, rows[0][0].to_i, "ten() did not return 10 as expected: #{rows.inspect}"
View
1  activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb
@@ -27,6 +27,7 @@ def test_class_names
post = Namespaced::Post.find_by_title( 'Great stuff', :include => :tagging )
assert_nil post.tagging
+ ActiveRecord::IdentityMap.clear
ActiveRecord::Base.store_full_sti_class = true
post = Namespaced::Post.find_by_title( 'Great stuff', :include => :tagging )
assert_instance_of Tagging, post.tagging
View
14 activerecord/test/cases/associations/eager_test.rb
@@ -185,7 +185,7 @@ def test_finding_with_includes_on_has_one_assocation_with_same_include_includes_
author = authors(:david)
post = author.post_about_thinking_with_last_comment
last_comment = post.last_comment
- author = assert_queries(3) { Author.find(author.id, :include => {:post_about_thinking_with_last_comment => :last_comment})} # find the author, then find the posts, then find the comments
+ author = assert_queries(ActiveRecord::IdentityMap.enabled? ? 2 : 3) { Author.find(author.id, :include => {:post_about_thinking_with_last_comment => :last_comment})} # find the author, then find the posts, then find the comments
assert_no_queries do
assert_equal post, author.post_about_thinking_with_last_comment
assert_equal last_comment, author.post_about_thinking_with_last_comment.last_comment
@@ -196,7 +196,7 @@ def test_finding_with_includes_on_belongs_to_association_with_same_include_inclu
post = posts(:welcome)
author = post.author
author_address = author.author_address
- post = assert_queries(3) { Post.find(post.id, :include => {:author_with_address => :author_address}) } # find the post, then find the author, then find the address
+ post = assert_queries(ActiveRecord::IdentityMap.enabled? ? 2 : 3) { Post.find(post.id, :include => {:author_with_address => :author_address}) } # find the post, then find the author, then find the address
assert_no_queries do
assert_equal author, post.author_with_address
assert_equal author_address, post.author_with_address.author_address
@@ -817,18 +817,18 @@ def test_eager_loading_with_conditions_on_joined_table_preloads
assert_equal [posts(:welcome)], posts
assert_equal authors(:david), assert_no_queries { posts[0].author}
- posts = assert_queries(2) do
+ posts = assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => [:comments], :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
end
assert_equal [posts(:welcome)], posts
assert_equal authors(:david), assert_no_queries { posts[0].author}
- posts = assert_queries(2) do
+ posts = assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
Post.find(:all, :include => :author, :joins => {:taggings => :tag}, :conditions => "tags.name = 'General'", :order => 'posts.id')
end
assert_equal posts(:welcome, :thinking), posts
- posts = assert_queries(2) do
+ posts = assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
Post.find(:all, :include => :author, :joins => {:taggings => {:tag => :taggings}}, :conditions => "taggings_tags.super_tag_id=2", :order => 'posts.id')
end
assert_equal posts(:welcome, :thinking), posts
@@ -842,7 +842,7 @@ def test_eager_loading_with_conditions_on_string_joined_table_preloads
assert_equal [posts(:welcome)], posts
assert_equal authors(:david), assert_no_queries { posts[0].author}
- posts = assert_queries(2) do
+ posts = assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => ["INNER JOIN comments on comments.post_id = posts.id"], :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
end
assert_equal [posts(:welcome)], posts
@@ -931,7 +931,7 @@ def test_preloading_empty_belongs_to
def test_preloading_empty_belongs_to_polymorphic
t = Tagging.create!(:taggable_type => 'Post', :taggable_id => Post.maximum(:id) + 1, :tag => tags(:general))
- tagging = assert_queries(2) { Tagging.preload(:taggable).find(t.id) }
+ tagging = assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) { Tagging.preload(:taggable).find(t.id) }
assert_no_queries { assert_nil tagging.taggable }
end
View
4 activerecord/test/cases/associations/has_one_through_associations_test.rb
@@ -88,12 +88,12 @@ def test_has_one_through_with_conditions_eager_loading
# conditions on the through table
assert_equal clubs(:moustache_club), Member.find(@member.id, :include => :favourite_club).favourite_club
memberships(:membership_of_favourite_club).update_attribute(:favourite, false)
- assert_equal nil, Member.find(@member.id, :include => :favourite_club).favourite_club
+ assert_equal nil, Member.find(@member.id, :include => :favourite_club).reload.favourite_club
# conditions on the source table
assert_equal clubs(:moustache_club), Member.find(@member.id, :include => :hairy_club).hairy_club
clubs(:moustache_club).update_attribute(:name, "Association of Clean-Shaven Persons")
- assert_equal nil, Member.find(@member.id, :include => :hairy_club).hairy_club
+ assert_equal nil, Member.find(@member.id, :include => :hairy_club).reload.hairy_club
end
def test_has_one_through_polymorphic_with_source_type
View
137 activerecord/test/cases/associations/identity_map_test.rb
@@ -0,0 +1,137 @@
+require "cases/helper"
+require 'models/author'
+require 'models/post'
+
+if ActiveRecord::IdentityMap.enabled?
+class InverseHasManyIdentityMapTest < ActiveRecord::TestCase
+ fixtures :authors, :posts
+
+ def test_parent_instance_should_be_shared_with_every_child_on_find
+ m = Author.first
+ is = m.posts
+ is.each do |i|
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to child-owned instance"
+ end
+ end
+
+ def test_parent_instance_should_be_shared_with_eager_loaded_children
+ m = Author.find(:first, :include => :posts)
+ is = m.posts
+ is.each do |i|
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to child-owned instance"
+ end
+
+ m = Author.find(:first, :include => :posts, :order => 'posts.id')
+ is = m.posts
+ is.each do |i|
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to child-owned instance"
+ end
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_built_child
+ m = Author.first
+ i = m.posts.build(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to just-built-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_block_style_built_child
+ m = Author.first
+ i = m.posts.build {|ii| ii.title = 'Industrial Revolution Re-enactment'; ii.body = 'Lorem ipsum'}
+ assert_not_nil i.title, "Child attributes supplied to build via blocks should be populated"
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to just-built-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_created_child
+ m = Author.first
+ i = m.posts.create(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_created_via_bang_method_child
+ m = Author.first
+ i = m.posts.create!(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_newly_block_style_created_child
+ m = Author.first
+ i = m.posts.create {|ii| ii.title = 'Industrial Revolution Re-enactment'; ii.body = 'Lorem ipsum'}
+ assert_not_nil i.title, "Child attributes supplied to create via blocks should be populated"
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_poked_in_child
+ m = Author.first
+ i = Post.create(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ m.posts << i
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to newly-created-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_replaced_via_accessor_children
+ m = Author.first
+ i = Post.new(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ m.posts = [i]
+ assert_same m, i.author
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to replaced-child-owned instance"
+ end
+
+ def test_parent_instance_should_be_shared_with_replaced_via_method_children
+ m = Author.first
+ i = Post.new(:title => 'Industrial Revolution Re-enactment', :body => 'Lorem ipsum')
+ m.posts = [i]
+ assert_not_nil i.author
+ assert_equal m.name, i.author.name, "Name of man should be the same before changes to parent instance"
+ m.name = 'Bongo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to parent instance"
+ i.author.name = 'Mungo'
+ assert_equal m.name, i.author.name, "Name of man should be the same after changes to replaced-child-owned instance"
+ end
+end
+end
View
2  activerecord/test/cases/associations/join_model_test.rb
@@ -88,7 +88,7 @@ def test_polymorphic_has_many_going_through_join_model_with_include_on_source_re
def test_polymorphic_has_many_going_through_join_model_with_custom_select_and_joins
assert_equal tags(:general), tag = posts(:welcome).tags.add_joins_and_select.first
- tag.author_id
+ assert_nothing_raised(NoMethodError) { tag.author_id }
end
def test_polymorphic_has_many_going_through_join_model_with_custom_foreign_key
View
5 activerecord/test/cases/autosave_association_test.rb
@@ -585,7 +585,7 @@ def test_a_marked_for_destruction_record_should_not_be_be_marked_after_reload
@pirate.ship.mark_for_destruction
assert !@pirate.reload.marked_for_destruction?
- assert !@pirate.ship.marked_for_destruction?
+ assert !@pirate.ship.reload.marked_for_destruction?
end
# has_one
@@ -1311,6 +1311,7 @@ class TestAutosaveAssociationValidationsOnAHasOneAssociation < ActiveRecord::Tes
def setup
@pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?")
@pirate.create_ship(:name => 'titanic')
+ super
end
test "should automatically validate associations with :validate => true" do
@@ -1319,7 +1320,7 @@ def setup
assert !@pirate.valid?
end
- test "should not automatically validate associations without :validate => true" do
+ test "should not automatically asd validate associations without :validate => true" do
assert @pirate.valid?
@pirate.non_validated_ship.name = ''
assert @pirate.valid?
View
12 activerecord/test/cases/helper.rb
@@ -11,7 +11,14 @@
require 'active_record'
require 'active_support/dependencies'
-require 'connection'
+begin
+ require 'connection'
+rescue LoadError
+ # If we cannot load connection we assume that driver was not loaded for this test case, so we load sqlite3 as default one.
+ # This allows for running separate test cases by simply running test file.
+ connection_type = defined?(JRUBY_VERSION) ? 'jdbc' : 'native'
+ require "test/connections/#{connection_type}_sqlite3/connection"
+end
# Show backtraces for deprecated behavior for quicker cleanup.
ActiveSupport::Deprecation.debug = true
@@ -19,6 +26,9 @@
# Quote "type" if it's a reserved word for the current connection.
QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name('type')
+# Enable Identity Map for testing
+ActiveRecord::IdentityMap.enabled = (ENV['IM'] == "false" ? false : true)
+
def current_adapter?(*types)
types.any? do |type|
ActiveRecord::ConnectionAdapters.const_defined?(type) &&
View
402 activerecord/test/cases/identity_map_test.rb
@@ -0,0 +1,402 @@
+require "cases/helper"
+require 'models/developer'
+require 'models/project'
+require 'models/company'
+require 'models/topic'
+require 'models/reply'
+require 'models/computer'
+require 'models/customer'
+require 'models/order'
+require 'models/post'
+require 'models/author'
+require 'models/tag'
+require 'models/tagging'
+require 'models/comment'
+require 'models/sponsor'
+require 'models/member'
+require 'models/essay'
+require 'models/subscriber'
+require "models/pirate"
+require "models/bird"
+require "models/parrot"
+
+if ActiveRecord::IdentityMap.enabled?
+class IdentityMapTest < ActiveRecord::TestCase
+ fixtures :accounts, :companies, :developers, :projects, :topics,
+ :developers_projects, :computers, :authors, :author_addresses,
+ :posts, :tags, :taggings, :comments, :subscribers
+
+ ##############################################################################
+ # Basic tests checking if IM is functioning properly on basic find operations#
+ ##############################################################################
+
+ def test_find_id
+ assert_same(Client.find(3), Client.find(3))
+ end
+
+ def test_find_id_without_identity_map
+ ActiveRecord::IdentityMap.without do
+ assert_not_same(Client.find(3), Client.find(3))
+ end
+ end
+
+ def test_find_id_use_identity_map
+ ActiveRecord::IdentityMap.enabled = false
+ ActiveRecord::IdentityMap.use do
+ assert_same(Client.find(3), Client.find(3))
+ end
+ ActiveRecord::IdentityMap.enabled = true
+ end
+
+ def test_find_pkey
+ assert_same(
+ Subscriber.find('swistak'),
+ Subscriber.find('swistak')
+ )
+ end
+
+ def test_find_by_id
+ assert_same(
+ Client.find_by_id(3),
+ Client.find_by_id(3)
+ )
+ end
+
+ def test_find_by_string_and_numeric_id
+ assert_same(
+ Client.find_by_id("3"),
+ Client.find_by_id(3)
+ )
+ end
+
+ def test_find_by_pkey
+ assert_same(
+ Subscriber.find_by_nick('swistak'),
+ Subscriber.find_by_nick('swistak')
+ )
+ end
+
+ def test_find_first_id
+ assert_same(
+ Client.find(:first, :conditions => {:id => 1}),
+ Client.find(:first, :conditions => {:id => 1})
+ )
+ end
+
+ def test_find_first_pkey
+ assert_same(
+ Subscriber.find(:first, :conditions => {:nick => 'swistak'}),
+ Subscriber.find(:first, :conditions => {:nick => 'swistak'})
+ )
+ end
+
+ ##############################################################################
+ # Tests checking if IM is functioning properly on more advanced finds #
+ # and associations #
+ ##############################################################################
+
+ def test_owner_object_is_associated_from_identity_map
+ post = Post.find(1)
+ comment = post.comments.first
+
+ assert_no_queries do
+ comment.post
+ end
+ assert_same post, comment.post
+ end
+
+ def test_associated_object_are_assigned_from_identity_map
+ post = Post.find(1)
+
+ post.comments.each do |comment|
+ assert_same post, comment.post
+ assert_equal post.object_id, comment.post.object_id
+ end
+ end
+
+ def test_creation
+ t1 = Topic.create("title" => "t1")
+ t2 = Topic.find(t1.id)
+ assert_same(t1, t2)
+ end
+
+ ##############################################################################
+ # Tests checking dirty attribute behaviour with IM #
+ ##############################################################################
+
+ def test_loading_new_instance_should_not_update_dirty_attributes
+ swistak = Subscriber.find(:first, :conditions => {:nick => 'swistak'})
+ swistak.name = "Swistak Sreberkowiec"
+ assert_equal(["name"], swistak.changed)
+ assert_equal({"name" => ["Marcin Raczkowski", "Swistak Sreberkowiec"]}, swistak.changes)
+
+ s = Subscriber.find('swistak')
+
+ assert swistak.name_changed?
+ assert_equal("Swistak Sreberkowiec", swistak.name)
+ end
+
+ def test_loading_new_instance_should_change_dirty_attribute_original_value
+ swistak = Subscriber.find(:first, :conditions => {:nick => 'swistak'})
+ swistak.name = "Swistak Sreberkowiec"
+
+ Subscriber.update_all({:name => "Raczkowski Marcin"}, {:name => "Marcin Raczkowski"})
+
+ s = Subscriber.find('swistak')
+
+ assert_equal({'name' => ["Raczkowski Marcin", "Swistak Sreberkowiec"]}, swistak.changes)
+ assert_equal("Swistak Sreberkowiec", swistak.name)
+ end
+
+ def test_loading_new_instance_should_remove_dirt
+ swistak = Subscriber.find(:first, :conditions => {:nick => 'swistak'})
+ swistak.name = "Swistak Sreberkowiec"
+
+ assert_equal({"name" => ["Marcin Raczkowski", "Swistak Sreberkowiec"]}, swistak.changes)
+
+ Subscriber.update_all({:name => "Swistak Sreberkowiec"}, {:name => "Marcin Raczkowski"})
+
+ s = Subscriber.find('swistak')
+
+ assert_equal("Swistak Sreberkowiec", swistak.name)
+ assert_equal({}, swistak.changes)
+ assert !swistak.name_changed?
+ end
+
+ def test_has_many_associations
+ pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?")
+ pirate.birds.create!(:name => 'Posideons Killer')
+ pirate.birds.create!(:name => 'Killer bandita Dionne')
+
+ posideons, killer = pirate.birds
+
+ pirate.reload
+
+ pirate.birds_attributes = [{ :id => posideons.id, :name => 'Grace OMalley' }]
+ assert_equal 'Grace OMalley', pirate.birds.to_a.find { |r| r.id == posideons.id }.name
+ end
+
+ def test_changing_associations
+ post1 = Post.create("title" => "One post", "body" => "Posting...")
+ post2 = Post.create("title" => "Another post", "body" => "Posting... Again...")
+ comment = Comment.new("body" => "comment")
+
+ comment.post = post1
+ assert comment.save
+
+ assert_same(post1.comments.first, comment)
+
+ comment.post = post2
+ assert comment.save
+
+ assert_same(post2.comments.first, comment)
+ assert_equal(0, post1.comments.size)
+ end
+
+ def test_im_with_polymorphic_has_many_going_through_join_model_with_custom_select_and_joins
+ tag = posts(:welcome).tags.first
+ tag_with_joins_and_select = posts(:welcome).tags.add_joins_and_select.first
+ assert_same(tag, tag_with_joins_and_select)
+ assert_nothing_raised(NoMethodError, "Joins/select was not loaded") { tag.author_id }
+ end
+
+ ##############################################################################
+ # Tests checking Identity Map behaviour with preloaded associations, joins, #
+ # includes etc. #
+ ##############################################################################
+
+ def test_find_with_preloaded_associations
+ assert_queries(2) do
+ posts = Post.preload(:comments)
+ assert posts.first.comments.first
+ end
+
+ # With IM we'll retrieve post object from previous query, it'll have comments
+ # already preloaded from first call
+ assert_queries(1) do
+ posts = Post.preload(:comments).to_a
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.preload(:author)
+ assert posts.first.author
+ end
+
+ # With IM we'll retrieve post object from previous query, it'll have comments
+ # already preloaded from first call
+ assert_queries(1) do
+ posts = Post.preload(:author).to_a
+ assert posts.first.author
+ end
+
+ assert_queries(1) do
+ posts = Post.preload(:author, :comments).to_a
+ assert posts.first.author
+ assert posts.first.comments.first
+ end
+ end
+
+ def test_find_with_included_associations
+ assert_queries(2) do
+ posts = Post.includes(:comments)
+ assert posts.first.comments.first
+ end
+
+ assert_queries(1) do
+ posts = Post.scoped.includes(:comments)
+ assert posts.first.comments.first
+ end
+
+ assert_queries(2) do
+ posts = Post.includes(:author)
+ assert posts.first.author
+ end
+
+ assert_queries(1) do
+ posts = Post.includes(:author, :comments).to_a
+ assert posts.first.author
+ assert posts.first.comments.first
+ end
+ end
+
+ def test_eager_loading_with_conditions_on_joined_table_preloads
+ posts = Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => [:comments], :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author}
+ assert_same posts.first.author, Author.first
+
+ posts = Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => [:comments], :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author}
+ assert_same posts.first.author, Author.first
+
+ posts = Post.find(:all, :include => :author, :joins => {:taggings => :tag}, :conditions => "tags.name = 'General'", :order => 'posts.id')
+ assert_equal posts(:welcome, :thinking), posts
+ assert_same posts.first.author, Author.first
+
+ posts = Post.find(:all, :include => :author, :joins => {:taggings => {:tag => :taggings}}, :conditions => "taggings_tags.super_tag_id=2", :order => 'posts.id')
+ assert_equal posts(:welcome, :thinking), posts
+ assert_same posts.first.author, Author.first
+ end
+
+ def test_eager_loading_with_conditions_on_string_joined_table_preloads
+ posts = assert_queries(2) do
+ Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => "INNER JOIN comments on comments.post_id = posts.id", :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
+ end
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author}
+
+ posts = assert_queries(1) do
+ Post.find(:all, :select => 'distinct posts.*', :include => :author, :joins => ["INNER JOIN comments on comments.post_id = posts.id"], :conditions => "comments.body like 'Thank you%'", :order => 'posts.id')
+ end
+ assert_equal [posts(:welcome)], posts
+ assert_equal authors(:david), assert_no_queries { posts[0].author}
+ end
+
+ ##############################################################################
+ # Behaviour releated to saving failures
+ ##############################################################################
+
+ def test_reload_object_if_save_failed
+ developer = Developer.first
+ developer.salary = 0
+
+ assert !developer.save
+
+ same_developer = Developer.first
+
+ assert_not_same developer, same_developer
+ assert_not_equal 0, same_developer.salary
+ assert_not_equal developer.salary, same_developer.salary
+ end
+
+ def test_reload_object_if_forced_save_failed
+ developer = Developer.first
+ developer.salary = 0
+
+ assert_raise(ActiveRecord::RecordInvalid) { developer.save! }
+
+ same_developer = Developer.first
+
+ assert_not_same developer, same_developer
+ assert_not_equal 0, same_developer.salary
+ assert_not_equal developer.salary, same_developer.salary
+ end
+
+ def test_reload_object_if_update_attributes_fails
+ developer = Developer.first
+ developer.salary = 0
+
+ assert !developer.update_attributes(:salary => 0)
+
+ same_developer = Developer.first
+
+ assert_not_same developer, same_developer
+ assert_not_equal 0, same_developer.salary
+ assert_not_equal developer.salary, same_developer.salary
+ end
+
+ ##############################################################################
+ # Behaviour of readonly, forzen, destroyed
+ ##############################################################################
+
+ def test_find_using_identity_map_respects_readonly_when_loading_associated_object_first
+ author = Author.first
+ readonly_comment = author.readonly_comments.first
+
+ comment = Comment.first
+ assert !comment.readonly?
+
+ assert readonly_comment.readonly?
+
+ assert_raise(ActiveRecord::ReadOnlyRecord) {readonly_comment.save}
+ assert comment.save
+ end
+
+ def test_find_using_identity_map_respects_readonly
+ comment = Comment.first
+ assert !comment.readonly?
+
+ author = Author.first
+ readonly_comment = author.readonly_comments.first
+
+ assert readonly_comment.readonly?
+
+ assert_raise(ActiveRecord::ReadOnlyRecord) {readonly_comment.save}
+ assert comment.save
+ end
+
+ def test_find_using_select_and_identity_map
+ author_id, author = Author.select('id').first, Author.first
+
+ assert_equal author_id, author
+ assert_same author_id, author
+ assert_not_nil author.name
+
+ post, post_id = Post.first, Post.select('id').first
+
+ assert_equal post_id, post
+ assert_same post_id, post
+ assert_not_nil post.title
+ end
+
+# Currently AR is not allowing changing primary key (see Persistence#update)
+# So we ignore it. If this changes, this test needs to be uncommented.
+# def test_updating_of_pkey
+# assert client = Client.find(3),
+# client.update_attribute(:id, 666)
+#
+# assert Client.find(666)
+# assert_same(client, Client.find(666))
+#
+# s = Subscriber.find_by_nick('swistak')
+# assert s.update_attribute(:nick, 'swistakTheJester')
+# assert_equal('swistakTheJester', s.nick)
+#
+# assert stj = Subscriber.find_by_nick('swistakTheJester')
+# assert_same(s, stj)
+# end
+
+end
+end
View
16 activerecord/test/cases/relations_test.rb
@@ -285,7 +285,7 @@ def test_find_with_preloaded_associations
assert posts.first.comments.first
end
- assert_queries(2) do
+ assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
posts = Post.preload(:comments).to_a
assert posts.first.comments.first
end
@@ -295,12 +295,12 @@ def test_find_with_preloaded_associations
assert posts.first.author
end
- assert_queries(2) do
+ assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
posts = Post.preload(:author).to_a
assert posts.first.author
end
- assert_queries(3) do
+ assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 3) do
posts = Post.preload(:author, :comments).to_a
assert posts.first.author
assert posts.first.comments.first
@@ -313,7 +313,7 @@ def test_find_with_included_associations
assert posts.first.comments.first
end
- assert_queries(2) do
+ assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do
posts = Post.scoped.includes(:comments)
assert posts.first.comments.first
end
@@ -323,7 +323,7 @@ def test_find_with_included_associations
assert posts.first.author
end
- assert_queries(3) do
+ assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 3) do
posts = Post.includes(:author, :comments).to_a
assert posts.first.author
assert posts.first.comments.first
@@ -603,8 +603,10 @@ def test_relation_merging_with_locks
end
def test_relation_merging_with_preload
- [Post.scoped.merge(Post.preload(:author)), Post.preload(:author).merge(Post.scoped)].each do |posts|
- assert_queries(2) { assert posts.first.author }
+ ActiveRecord::IdentityMap.without do
+ [Post.scoped.merge(Post.preload(:author)), Post.preload(:author).merge(Post.scoped)].each do |posts|
+ assert_queries(2) { assert posts.first.author }
+ end
end
end
View
4 activerecord/test/fixtures/subscribers.yml
@@ -5,3 +5,7 @@ first:
second:
nick: webster132
name: David Heinemeier Hansson
+
+thrid:
+ nick: swistak
+ name: Marcin Raczkowski
View
5 railties/lib/rails/generators/rails/app/templates/config/application.rb
@@ -57,5 +57,10 @@ class Application < Rails::Application
# Configure sensitive parame