bullet 2.0.0.beta.2 → 2.1.0

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.
Files changed (49) hide show
  1. data/.gitignore +9 -0
  2. data/.rspec +2 -0
  3. data/.rvmrc +2 -0
  4. data/.rvmrc.example +2 -0
  5. data/.watchr +24 -0
  6. data/Gemfile +15 -0
  7. data/Gemfile.lock +105 -0
  8. data/Hacking.textile +69 -0
  9. data/MIT-LICENSE +1 -1
  10. data/README.textile +61 -49
  11. data/README_for_rails2.textile +41 -42
  12. data/Rakefile +40 -29
  13. data/bullet.gemspec +17 -60
  14. data/lib/bullet/action_controller2.rb +6 -6
  15. data/lib/bullet/active_record2.rb +17 -16
  16. data/lib/bullet/active_record3.rb +17 -25
  17. data/lib/bullet/active_record31.rb +96 -0
  18. data/lib/bullet/detector/association.rb +144 -0
  19. data/lib/bullet/detector/base.rb +12 -0
  20. data/lib/bullet/detector/counter.rb +43 -0
  21. data/lib/bullet/detector/n_plus_one_query.rb +39 -0
  22. data/lib/bullet/detector/unused_eager_association.rb +41 -0
  23. data/lib/bullet/detector.rb +9 -0
  24. data/lib/bullet/notification/base.rb +61 -0
  25. data/lib/bullet/notification/counter_cache.rb +13 -0
  26. data/lib/bullet/notification/n_plus_one_query.rb +32 -0
  27. data/lib/bullet/notification/unused_eager_loading.rb +14 -0
  28. data/lib/bullet/notification.rb +4 -79
  29. data/lib/bullet/notification_collector.rb +25 -0
  30. data/lib/bullet/rack.rb +44 -0
  31. data/lib/bullet/registry/association.rb +15 -0
  32. data/lib/bullet/registry/base.rb +37 -0
  33. data/lib/bullet/registry/object.rb +15 -0
  34. data/lib/bullet/registry.rb +7 -0
  35. data/lib/bullet/version.rb +5 -0
  36. data/lib/bullet.rb +64 -43
  37. data/perf/benchmark.rb +106 -0
  38. data/spec/bullet/association_for_chris_spec.rb +6 -6
  39. data/spec/bullet/association_for_peschkaj_spec.rb +6 -6
  40. data/spec/bullet/association_spec.rb +140 -294
  41. data/spec/bullet/counter_spec.rb +10 -10
  42. data/spec/spec_helper.rb +51 -17
  43. metadata +72 -57
  44. data/VERSION +0 -1
  45. data/lib/bullet/association.rb +0 -294
  46. data/lib/bullet/counter.rb +0 -101
  47. data/lib/bullet/logger.rb +0 -9
  48. data/lib/bulletware.rb +0 -42
  49. data/spec/spec.opts +0 -3
@@ -0,0 +1,96 @@
1
+ module Bullet
2
+ LOAD_TARGET_RGX = /load_target/
3
+ module ActiveRecord
4
+ def self.enable
5
+ require 'active_record'
6
+ ::ActiveRecord::Relation.class_eval do
7
+ alias_method :origin_to_a, :to_a
8
+ # if select a collection of objects, then these objects have possible to cause N+1 query.
9
+ # if select only one object, then the only one object has impossible to cause N+1 query.
10
+ def to_a
11
+ records = origin_to_a
12
+ if records.size > 1
13
+ Bullet::Detector::Association.add_possible_objects(records)
14
+ Bullet::Detector::Counter.add_possible_objects(records)
15
+ elsif records.size == 1
16
+ Bullet::Detector::Association.add_impossible_object(records.first)
17
+ Bullet::Detector::Counter.add_impossible_object(records.first)
18
+ end
19
+ records
20
+ end
21
+ end
22
+
23
+ ::ActiveRecord::Associations::Preloader.class_eval do
24
+ # include query for one to many associations.
25
+ # keep this eager loadings.
26
+ alias_method :origin_initialize, :initialize
27
+ def initialize(records, associations, preload_options = {})
28
+ origin_initialize(records, associations, preload_options)
29
+ records = [records].flatten.compact.uniq
30
+ return if records.empty?
31
+ records.each do |record|
32
+ Bullet::Detector::Association.add_object_associations(record, associations)
33
+ end
34
+ Bullet::Detector::Association.add_eager_loadings(records, associations)
35
+ end
36
+ end
37
+
38
+ ::ActiveRecord::FinderMethods.class_eval do
39
+ # add includes in scope
40
+ alias_method :origin_find_with_associations, :find_with_associations
41
+ def find_with_associations
42
+ records = origin_find_with_associations
43
+ associations = (@eager_load_values + @includes_values).uniq
44
+ records.each do |record|
45
+ Bullet::Detector::Association.add_object_associations(record, associations)
46
+ Bullet::Detector::NPlusOneQuery.call_association(record, associations)
47
+ end
48
+ Bullet::Detector::Association.add_eager_loadings(records, associations)
49
+ records
50
+ end
51
+ end
52
+
53
+ ::ActiveRecord::Associations::JoinDependency.class_eval do
54
+ alias_method :origin_construct_association, :construct_association
55
+ # call join associations
56
+ def construct_association(record, join, row)
57
+ associations = join.reflection.name
58
+ Bullet::Detector::Association.add_object_associations(record, associations)
59
+ Bullet::Detector::NPlusOneQuery.call_association(record, associations)
60
+ origin_construct_association(record, join, row)
61
+ end
62
+ end
63
+
64
+ ::ActiveRecord::Associations::CollectionAssociation.class_eval do
65
+ # call one to many associations
66
+ alias_method :origin_load_target, :load_target
67
+ def load_target
68
+ Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name)
69
+ origin_load_target
70
+ end
71
+ end
72
+
73
+ ::ActiveRecord::Associations::Association.class_eval do
74
+ # call has_one and belong_to association
75
+ alias_method :origin_load_target, :load_target
76
+ def load_target
77
+ # avoid stack level too deep
78
+ result = origin_load_target
79
+ Bullet::Detector::NPlusOneQuery.call_association(@owner, @reflection.name) unless caller.find {|c| c =~ LOAD_TARGET_RGX }
80
+ Bullet::Detector::Association.add_possible_objects(result)
81
+ result
82
+ end
83
+ end
84
+
85
+ ::ActiveRecord::Associations::HasManyAssociation.class_eval do
86
+ alias_method :origin_has_cached_counter?, :has_cached_counter?
87
+
88
+ def has_cached_counter?
89
+ result = origin_has_cached_counter?
90
+ Bullet::Detector::Counter.add_counter_cache(@owner, @reflection.name) unless result
91
+ result
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,144 @@
1
+ module Bullet
2
+ module Detector
3
+ class Association < Base
4
+ class <<self
5
+ def start_request
6
+ @@checked = false
7
+ end
8
+
9
+ def clear
10
+ # Note that under ruby class variables are shared among the class
11
+ # that declares them and all classes derived from that class.
12
+ # The following variables are accessible by all classes that
13
+ # derive from Bullet::Detector::Association - changing the variable
14
+ # in one subclass will make the change visible to all subclasses!
15
+ @@object_associations = nil
16
+ @@callers = nil
17
+ @@possible_objects = nil
18
+ @@impossible_objects = nil
19
+ @@call_object_associations = nil
20
+ @@eager_loadings = nil
21
+ end
22
+
23
+ def add_object_associations(object, associations)
24
+ object_associations.add( object, associations )
25
+ end
26
+
27
+ def add_call_object_associations(object, associations)
28
+ call_object_associations.add( object, associations )
29
+ end
30
+
31
+ def add_possible_objects(objects)
32
+ possible_objects.add objects
33
+ end
34
+
35
+ def add_impossible_object(object)
36
+ impossible_objects.add object
37
+ end
38
+
39
+ def add_eager_loadings(objects, associations)
40
+ objects = Array(objects)
41
+
42
+ to_add = nil
43
+ to_merge, to_delete = [], []
44
+ eager_loadings.each do |k, v|
45
+ key_objects_overlap = k & objects
46
+
47
+ next if key_objects_overlap.empty?
48
+
49
+ if key_objects_overlap == k
50
+ to_add = [k, associations]
51
+ break
52
+
53
+ else
54
+ to_merge << [key_objects_overlap, ( eager_loadings[k].dup << associations )]
55
+
56
+ keys_without_objects = k - objects
57
+ to_merge << [keys_without_objects, eager_loadings[k]]
58
+ to_delete << k
59
+ objects = objects - k
60
+ end
61
+ end
62
+ if to_add
63
+ eager_loadings.add *to_add
64
+ end
65
+ to_merge.each do |k,val|
66
+ eager_loadings.merge k, val
67
+ end
68
+ to_delete.each do |k|
69
+ eager_loadings.delete k
70
+ end
71
+
72
+ eager_loadings.add objects, associations unless objects.empty?
73
+ end
74
+
75
+ private
76
+ def possible?(object)
77
+ possible_objects.contains? object
78
+ end
79
+
80
+ def impossible?(object)
81
+ impossible_objects.contains? object
82
+ end
83
+
84
+ # check if object => associations already exists in object_associations.
85
+ def association?(object, associations)
86
+ value = object_associations[object]
87
+ if value
88
+ value.each do |v|
89
+ result = v.is_a?(Hash) ? v.has_key?(associations) : v == associations
90
+ return true if result
91
+ end
92
+ end
93
+
94
+ return false
95
+ end
96
+
97
+ # object_associations keep the object relationships
98
+ # that the object has many associations.
99
+ # e.g. { <Post id:1> => [:comments] }
100
+ # the object_associations keep all associations that may be or may no be
101
+ # unpreload associations or unused preload associations.
102
+ def object_associations
103
+ @@object_associations ||= Bullet::Registry::Base.new
104
+ end
105
+
106
+ # call_object_assciations keep the object relationships
107
+ # that object.associations is called.
108
+ # e.g. { <Post id:1> => [:comments] }
109
+ # they are used to detect unused preload associations.
110
+ def call_object_associations
111
+ @@call_object_associations ||= Bullet::Registry::Base.new
112
+ end
113
+
114
+ # possible_objects keep the class to object relationships
115
+ # that the objects may cause N+1 query.
116
+ # e.g. { Post => [<Post id:1>, <Post id:2>] }
117
+ def possible_objects
118
+ @@possible_objects ||= Bullet::Registry::Object.new
119
+ end
120
+
121
+ # impossible_objects keep the class to objects relationships
122
+ # that the objects may not cause N+1 query.
123
+ # e.g. { Post => [<Post id:1>, <Post id:2>] }
124
+ # Notice: impossible_objects are not accurate,
125
+ # if find collection returns only one object, then the object is impossible object,
126
+ # impossible_objects are used to avoid treating 1+1 query to N+1 query.
127
+ def impossible_objects
128
+ @@impossible_objects ||= Bullet::Registry::Object.new
129
+ end
130
+
131
+ # eager_loadings keep the object relationships
132
+ # that the associations are preloaded by find :include.
133
+ # e.g. { [<Post id:1>, <Post id:2>] => [:comments, :user] }
134
+ def eager_loadings
135
+ @@eager_loadings ||= Bullet::Registry::Association.new
136
+ end
137
+
138
+ def callers
139
+ @@callers ||= []
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,12 @@
1
+ module Bullet
2
+ module Detector
3
+ class Base
4
+ def self.start_request
5
+ end
6
+
7
+ def self.end_request
8
+ clear
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,43 @@
1
+ module Bullet
2
+ module Detector
3
+ class Counter < Base
4
+ def self.clear
5
+ @@possible_objects = nil
6
+ @@impossible_objects = nil
7
+ end
8
+
9
+ def self.add_counter_cache(object, associations)
10
+ if conditions_met?( object, associations )
11
+ create_notification object.class, associations
12
+ end
13
+ end
14
+
15
+ def self.add_possible_objects(objects)
16
+ possible_objects.add objects
17
+ end
18
+
19
+ def self.add_impossible_object(object)
20
+ impossible_objects.add object
21
+ end
22
+
23
+ private
24
+ def self.create_notification( klazz, associations )
25
+ notice = Bullet::Notification::CounterCache.new klazz, associations
26
+ Bullet.notification_collector.add notice
27
+ end
28
+
29
+ def self.possible_objects
30
+ @@possible_objects ||= Bullet::Registry::Object.new
31
+ end
32
+
33
+ def self.impossible_objects
34
+ @@impossible_objects ||= Bullet::Registry::Object.new
35
+ end
36
+
37
+ def self.conditions_met?( object, associations )
38
+ possible_objects.contains?( object ) and
39
+ !impossible_objects.contains?( object )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ module Bullet
2
+ module Detector
3
+ class NPlusOneQuery < Association
4
+ # executed when object.assocations is called.
5
+ # first, it keeps this method call for object.association.
6
+ # then, it checks if this associations call is unpreload.
7
+ # if it is, keeps this unpreload associations and caller.
8
+ def self.call_association(object, associations)
9
+ @@checked = true
10
+ add_call_object_associations(object, associations)
11
+
12
+ if conditions_met?(object, associations)
13
+ caller_in_project
14
+ create_notification object.class, associations
15
+ end
16
+ end
17
+
18
+ private
19
+ def self.create_notification(klazz, associations)
20
+ notice = Bullet::Notification::NPlusOneQuery.new( callers, klazz, associations )
21
+ Bullet.notification_collector.add( notice )
22
+ end
23
+
24
+ # decide whether the object.associations is unpreloaded or not.
25
+ def self.conditions_met?(object, associations)
26
+ possible?(object) and
27
+ !impossible?(object) and
28
+ !association?(object, associations)
29
+ end
30
+
31
+ def self.caller_in_project
32
+ vender_root ||= File.join(Rails.root, 'vendor')
33
+ callers << caller.select { |c| c =~ /#{Rails.root}/ }.
34
+ reject { |c| c =~ /#{vender_root}/ }
35
+ callers.uniq!
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ module Bullet
2
+ module Detector
3
+ class UnusedEagerAssociation < Association
4
+ # check if there are unused preload associations.
5
+ # for each object => association
6
+ # get related_objects from eager_loadings associated with object and associations
7
+ # get call_object_association from associations of call_object_associations whose object is in related_objects
8
+ # if association not in call_object_association, then the object => association - call_object_association is ununsed preload assocations
9
+ def self.check_unused_preload_associations
10
+ @@checked = true
11
+ object_associations.each do |object, association|
12
+ object_association_diff = diff_object_association object, association
13
+ next if object_association_diff.empty?
14
+
15
+ create_notification object.class, object_association_diff
16
+ end
17
+ end
18
+
19
+ protected
20
+ def self.create_notification(klazz, associations)
21
+ notice = Bullet::Notification::UnusedEagerLoading.new( klazz, associations )
22
+ Bullet.notification_collector.add( notice )
23
+ end
24
+
25
+ def self.call_object_association( object, association )
26
+ all = Set.new
27
+ eager_loadings.similarly_associated( object, association ).each do |related_object|
28
+ coa = call_object_associations[related_object]
29
+ next if coa.nil?
30
+ all.merge coa
31
+ end
32
+ all.to_a
33
+ end
34
+
35
+ def self.diff_object_association( object, association )
36
+ potential_objects = association - call_object_association( object, association )
37
+ potential_objects.reject {|a| a.is_a?( Hash ) }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ module Bullet
2
+ module Detector
3
+ autoload :Base, 'bullet/detector/base'
4
+ autoload :Association, 'bullet/detector/association'
5
+ autoload :NPlusOneQuery, 'bullet/detector/n_plus_one_query'
6
+ autoload :UnusedEagerAssociation, 'bullet/detector/unused_eager_association'
7
+ autoload :Counter, 'bullet/detector/counter'
8
+ end
9
+ end
@@ -0,0 +1,61 @@
1
+ module Bullet
2
+ module Notification
3
+ class Base
4
+ attr_accessor :notifier, :url
5
+ attr_reader :base_class, :associations, :path
6
+
7
+ def initialize( base_class, associations, path = nil )
8
+ @base_class = base_class
9
+ @associations = associations.is_a?( Array ) ? associations : [ associations ]
10
+ @path = path
11
+ end
12
+
13
+ def title
14
+ end
15
+
16
+ def body
17
+ end
18
+
19
+ def whoami
20
+ "user: " << `whoami`.chomp
21
+ end
22
+
23
+ def body_with_caller
24
+ body
25
+ end
26
+
27
+ def standard_notice
28
+ @standard_notifice ||= title + "\n" + body
29
+ end
30
+
31
+ def full_notice
32
+ [whoami, url, title, body_with_caller].compact.join("\n")
33
+ end
34
+
35
+ def notify_inline
36
+ self.notifier.inline_notify( self.full_notice )
37
+ end
38
+
39
+ def notify_out_of_channel
40
+ self.notifier.out_of_channel_notify( self.full_notice )
41
+ end
42
+
43
+ def eql?( other )
44
+ klazz_associations_str == other.klazz_associations_str
45
+ end
46
+
47
+ def hash
48
+ klazz_associations_str.hash
49
+ end
50
+
51
+ protected
52
+ def klazz_associations_str
53
+ " #{@base_class} => [#{@associations.map(&:inspect).join(', ')}]"
54
+ end
55
+
56
+ def associations_str
57
+ ":include => #{@associations.map{|a| a.to_s.to_sym unless a.is_a? Hash}.inspect}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,13 @@
1
+ module Bullet
2
+ module Notification
3
+ class CounterCache < Base
4
+ def body
5
+ klazz_associations_str
6
+ end
7
+
8
+ def title
9
+ "Need Counter Cache"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ module Bullet
2
+ module Notification
3
+ class NPlusOneQuery < Base
4
+ def initialize( callers, base_class, associations, path = nil )
5
+ super( base_class, associations, path )
6
+
7
+ @callers = callers
8
+ end
9
+
10
+ def body_with_caller
11
+ "#{body}\n#{call_stack_messages}"
12
+ end
13
+
14
+ def body
15
+ "#{klazz_associations_str}\n Add to your finder: #{associations_str}"
16
+ end
17
+
18
+ def title
19
+ "N+1 Query #{@path ? "in #{@path}" : 'detected'}"
20
+ end
21
+
22
+ protected
23
+ def call_stack_messages
24
+ @callers.collect do |c|
25
+ [ 'N+1 Query method call stack',
26
+ c.collect {|line| " #{line}"} ].flatten
27
+ end.join( "\n" )
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ module Bullet
2
+ module Notification
3
+ class UnusedEagerLoading < Base
4
+ def body
5
+ "#{klazz_associations_str}\n Remove from your finder: #{associations_str}"
6
+ end
7
+
8
+ def title
9
+ "Unused Eager Loading #{@path ? "in #{@path}" : 'detected'}"
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -1,83 +1,8 @@
1
1
  module Bullet
2
- class NotificationError < StandardError
3
- end
4
-
5
2
  module Notification
6
- def notification?
7
- end
8
-
9
- def notification_response
10
- end
11
-
12
- def console_title
13
- end
14
-
15
- def log_message(path = nil)
16
- end
17
-
18
- def javascript_notification
19
- str = ''
20
- if Bullet.alert || Bullet.console
21
- response = notification_response
22
- end
23
- unless response.blank?
24
- if Bullet.alert
25
- str << wrap_js_association("alert(#{response.join("\n").inspect});")
26
- end
27
- if Bullet.console
28
- code = <<-CODE
29
- if (typeof(console) !== 'undefined') {
30
-
31
- if (console.groupCollapsed && console.groupEnd && console.log) {
32
-
33
- console.groupCollapsed(#{console_title.join(', ').inspect});
34
- console.log(#{response.join("\n").inspect});
35
- console.log(#{call_stack_messages.join("\n").inspect});
36
- console.groupEnd();
37
-
38
- } else if (console.log) {
39
-
40
- console.log(#{response.join("\n").inspect});
41
- }
42
- }
43
- CODE
44
- str << wrap_js_association(code)
45
- end
46
- end
47
- str
48
- end
49
-
50
- def growl_notification
51
- if Bullet.growl
52
- response = notification_response
53
- unless response.blank?
54
- begin
55
- growl = Growl.new('localhost', 'ruby-growl', ['Bullet Notification'], nil, Bullet.growl_password)
56
- growl.notify('Bullet Notification', 'Bullet Notification', response.join("\n"))
57
- rescue
58
- end
59
- end
60
- end
61
- end
62
-
63
- def log_notification(path)
64
- if Bullet.bullet_logger || Bullet.rails_logger
65
- Rails.logger.warn '' if Bullet.rails_logger
66
- messages = log_messages(path)
67
- messages.each do |message|
68
- Bullet.logger.info(message.join("\n")) if Bullet.bullet_logger
69
- Rails.logger.warn(message.join("\n")) if Bullet.rails_logger
70
- end
71
- Bullet.logger_file.flush if Bullet.bullet_logger
72
- end
73
- end
74
-
75
- private
76
- def wrap_js_association(message)
77
- str = ''
78
- str << "<script type=\"text/javascript\">/*<![CDATA[*/"
79
- str << message
80
- str << "/*]]>*/</script>\n"
81
- end
3
+ autoload :Base, 'bullet/notification/base'
4
+ autoload :UnusedEagerLoading, 'bullet/notification/unused_eager_loading'
5
+ autoload :NPlusOneQuery, 'bullet/notification/n_plus_one_query'
6
+ autoload :CounterCache, 'bullet/notification/counter_cache'
82
7
  end
83
8
  end
@@ -0,0 +1,25 @@
1
+ require 'set'
2
+
3
+ module Bullet
4
+ class NotificationCollector
5
+ attr_reader :collection
6
+
7
+ def initialize
8
+ reset
9
+ end
10
+
11
+ def reset
12
+ @collection = Set.new
13
+ end
14
+
15
+ def add( value )
16
+ @collection << value
17
+ end
18
+
19
+ def notifications_present?
20
+ !@collection.empty?
21
+ end
22
+
23
+ end
24
+ end
25
+
@@ -0,0 +1,44 @@
1
+ module Bullet
2
+ class Rack
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ return @app.call(env) unless Bullet.enable?
9
+
10
+ Bullet.start_request
11
+ status, headers, response = @app.call(env)
12
+ return [status, headers, response] if empty?(response)
13
+
14
+ if Bullet.notification?
15
+ if status == 200 and !response.body.frozen? and check_html?(headers, response)
16
+ response_body = response.body << Bullet.gather_inline_notifications
17
+ headers['Content-Length'] = response_body.length.to_s
18
+ end
19
+ Bullet.perform_out_of_channel_notifications(env)
20
+ end
21
+ response_body ||= response.body
22
+ Bullet.end_request
23
+ no_browser_cache(headers) if Bullet.disable_browser_cache
24
+ [status, headers, [response_body]]
25
+ end
26
+
27
+ # fix issue if response's body is a Proc
28
+ def empty?(response)
29
+ # response may be ["Not Found"], ["Move Permanently"], etc.
30
+ (response.is_a?(Array) && response.size <= 1) ||
31
+ !response.body.is_a?(String) || response.body.empty?
32
+ end
33
+
34
+ def check_html?(headers, response)
35
+ headers['Content-Type'] and headers['Content-Type'].include? 'text/html' and response.body =~ %r{<html.*</html>}m
36
+ end
37
+
38
+ def no_browser_cache(headers)
39
+ headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
40
+ headers["Pragma"] = "no-cache"
41
+ headers["Expires"] = "Wed, 09 Sep 2009 09:09:09 GMT"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ module Bullet
2
+ module Registry
3
+ class Association < Base
4
+ def merge( base, associations )
5
+ @registry.merge!( { base => associations } )
6
+ end
7
+
8
+ def similarly_associated( base, associations )
9
+ @registry.select do |key, value|
10
+ key.include?( base ) and value == associations
11
+ end.collect( &:first ).flatten
12
+ end
13
+ end
14
+ end
15
+ end