association_observers 0.0.5 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. data/.gitignore +1 -0
  2. data/.travis.yml +1 -0
  3. data/Gemfile +6 -0
  4. data/README.md +57 -2
  5. data/Rakefile +18 -0
  6. data/association_observers.gemspec +3 -3
  7. data/lib/association_observers.rb +43 -31
  8. data/lib/association_observers/{activerecord.rb → active_record.rb} +15 -16
  9. data/lib/association_observers/{datamapper.rb → data_mapper.rb} +11 -20
  10. data/lib/association_observers/extensions/delayed_job.rb +13 -0
  11. data/lib/association_observers/extensions/resque.rb +30 -0
  12. data/lib/association_observers/extensions/sidekiq.rb +32 -0
  13. data/lib/association_observers/notifiers/base.rb +46 -21
  14. data/lib/association_observers/notifiers/propagation_notifier.rb +2 -2
  15. data/lib/association_observers/orm/active_record.rb +48 -0
  16. data/lib/association_observers/orm/base.rb +61 -0
  17. data/lib/association_observers/orm/data_mapper.rb +61 -0
  18. data/lib/association_observers/queue.rb +71 -0
  19. data/lib/association_observers/railtie.rb +11 -4
  20. data/lib/association_observers/ruby18.rb +26 -8
  21. data/lib/association_observers/version.rb +1 -1
  22. data/lib/association_observers/workers/many_delayed_notification.rb +41 -0
  23. data/spec/activerecord/association_observers_spec.rb +32 -1
  24. data/spec/activerecord/delayed_job/association_observers_spec.rb +8 -0
  25. data/spec/activerecord/readme_example_spec.rb +1 -1
  26. data/spec/activerecord/resque/association_observers_spec.rb +8 -0
  27. data/spec/activerecord/sidekiq/association_observers_spec.rb +8 -0
  28. data/spec/datamapper/association_observers_spec.rb +37 -2
  29. data/spec/{active_record_helper.rb → helpers/active_record_helper.rb} +4 -1
  30. data/spec/{datamapper_helper.rb → helpers/datamapper_helper.rb} +1 -1
  31. data/spec/helpers/delayed_job_helper.rb +26 -0
  32. data/spec/helpers/resque_helper.rb +5 -0
  33. data/spec/helpers/sidekiq_helper.rb +4 -0
  34. metadata +174 -156
@@ -0,0 +1,30 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module AssociationObservers
4
+ module Workers
5
+ class ManyDelayedNotification
6
+ @queue = AssociationObservers::options[:queue][:name].to_sym
7
+
8
+ alias :standard_initialize :initialize
9
+ def initialize(*args)
10
+ standard_initialize(*args)
11
+ # notifier has been dumped two times, reset it here
12
+ @notifier = args.last
13
+ end
14
+
15
+ def self.perform(*args)
16
+ self.new(*args).perform
17
+ end
18
+ end
19
+ end
20
+
21
+ class Queue
22
+ private
23
+
24
+ # overwriting of the enqueue method, using the Resque enqueue method already
25
+ def enqueue(task, *args)
26
+ Resque.enqueue(task, *args[0..-2] << Marshal.dump(args.last))
27
+ end
28
+ end
29
+ end
30
+
@@ -0,0 +1,32 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module AssociationObservers
3
+ module Workers
4
+ class ManyDelayedNotification
5
+ include Sidekiq::Worker
6
+
7
+ sidekiq_options :queue => AssociationObservers::options[:queue][:name].to_sym
8
+
9
+ alias :perform_action! :perform
10
+ alias :standard_initialize :initialize
11
+
12
+ def initialize ; ; end
13
+
14
+ def perform(*args)
15
+ standard_initialize(*args)
16
+ # notifier has been dumped two times, reset it here
17
+ @notifier = args.last
18
+ perform_action!
19
+ end
20
+ end
21
+ end
22
+
23
+ class Queue
24
+ private
25
+
26
+ # overwriting of the method. Sidekiq workers use a method called perform_async
27
+ def enqueue(task, *args)
28
+ task.perform_async(*args[0..-2] << Marshal.dump(args.last))
29
+ end
30
+ end
31
+ end
32
+
@@ -2,14 +2,19 @@
2
2
  module Notifier
3
3
  class Base
4
4
  attr_reader :callback, :observers, :options
5
- # @param [Symbol] callback callback key to which this observer will respond (:create, :update, :save, :destroy)
6
- # @param [Array] observers list of observers asssociations in the symbolized-underscored form (SimpleAssociation => :simple_association)
7
- # @param [Hash] options additional options for the notifier
8
- # @option options [Class] :observer_class the class of the observer (important when the observer association is polymorphic)
9
- def initialize(callback, observers, options = {})
10
- @callback = callback
11
- @observers = Array(observers)
12
- @options = options
5
+ # @overload initialize(callback, observers, options)
6
+ # Initializes a usable notifier
7
+ # @param [Symbol] callback callback key to which this observer will respond (:create, :update, :save, :destroy)
8
+ # @param [Array] observers list of observers asssociations in the symbolized-underscored form (SimpleAssociation => :simple_association)
9
+ # @param [Hash] options additional options for the notifier
10
+ # @option options [Class] :observer_class the class of the observer (important when the observer association is polymorphic)
11
+ # @overload initialize
12
+ # Initializes a ghost notifier for idempotent method extraction purposes
13
+ def initialize(*args)
14
+ @options = args.extract_options!
15
+ raise "something is wrong, the notifiers was wrongly initialized" unless args.size == 0 or args.size == 2
16
+ @callback, @observers = args
17
+ @observers = Array(@observers)
13
18
  end
14
19
 
15
20
 
@@ -17,11 +22,14 @@ module Notifier
17
22
  # implemented as a filter where it is seen if the triggered callback corresponds to the callback this observer
18
23
  # responds to
19
24
  #
20
- # @param [Symbol] callback key from the callback that has just been triggered
25
+ # @param [Array] args [callback: key from the callback that has just been triggered, to_exclude: list of associations to exclude from the notifying chain]
21
26
  # @param [Object] observable the object which triggered the callback
22
- def update(callback, observable)
23
- return unless callback == @callback
24
- observers = @options.has_key?(:observer_class) ? self.observers.select{|assoc| observable.association(assoc).klass == @options[:observer_class] } : self.observers
27
+ def update(args, observable)
28
+ callback, to_exclude = args
29
+ return unless accepted_callback?(callback)
30
+ observers = self.observers
31
+ observers = observers.reject{|assoc| to_exclude.include?(assoc) }
32
+ observers = observers.select{|assoc| observable.association(assoc).klass == @options[:observer_class] } if @options.has_key?(:observer_class)
25
33
  notify(observable, observers.map{|assoc| observable.send(assoc)}.compact)
26
34
  end
27
35
 
@@ -39,14 +47,24 @@ module Notifier
39
47
 
40
48
  private
41
49
 
50
+ # helper method which checks whether the given callback is compatible with the notifier callback.
51
+ # Example: if the notifier is marked for :save, then :create is a valid callback.
52
+ # if the notifier is marked for :update, then :create is not a valid callback.
53
+ def accepted_callback?(callback)
54
+ case @callback
55
+ when :save then [:create, :update, :save].include?(callback)
56
+ when :update then [:update, :save].include?(callback)
57
+ else callback == @callback
58
+ end
59
+ end
60
+
42
61
  # Notifies all observers; filters the observers into two groups: one-to-many and one-to-one collections
43
62
  # @param [Object] observable the object which is notifying
44
63
  # @param [Array] observers the associated observers which will be notified
45
- def notify(observable, observers, &block)
64
+ def notify(observable, observers)
46
65
  many, ones = observers.partition{|obs| obs.respond_to?(:size) }
47
- action = block_given? ? block : method(:action)
48
- notify_many(observable, many, &action)
49
- notify_ones(observable, ones, &action)
66
+ notify_many(observable, many)
67
+ notify_ones(observable, ones)
50
68
  end
51
69
 
52
70
  # TODO: make this notify private as soon as possible again
@@ -58,9 +76,7 @@ module Notifier
58
76
  # @param [Array[ActiveRecord::Relation]] many_observers the observers which will be notified; each element represents a one-to-many relation
59
77
  def notify_many(observable, many_observers)
60
78
  many_observers.each do |observers|
61
- AssociationObservers::batched_each(observers, 10) do |observer|
62
- yield(observable, observer) if conditions(observable, observer)
63
- end if conditions_many(observable, observers)
79
+ AssociationObservers::queue.enqueue_notifications(observers, observable, self) if conditions_many(observable, observers)
64
80
  end
65
81
  end
66
82
 
@@ -69,9 +85,18 @@ module Notifier
69
85
  # @param [Object] observable the object which is notifying
70
86
  # @param [Array[Object]] observers the observers which will be notified; each element represents a one-to-one association
71
87
  def notify_ones(observable, observers)
72
- observers.each do |observer|
73
- yield(observable, observer) if conditions(observable, observer)
88
+ observers.each do |uniq_observer|
89
+ AssociationObservers::queue.enqueue_notifications([uniq_observer], observable, self,
90
+ :batch_size => 1, :klass => uniq_observer.class)
74
91
  end
75
92
  end
93
+
94
+ # conditionally executes the notifier action. This is explicitly here so that its call can be queued and
95
+ # the background worker can call it. I don't like it, but it was decided this way because we can't marshal
96
+ # procs, therefore we can't pass procs to the workers. It is a necessary evil.
97
+ def conditional_action(observable, observer)
98
+ action(observable, observer) if conditions(observable, observer)
99
+ end
100
+
76
101
  end
77
102
  end
@@ -5,12 +5,12 @@
5
5
  class PropagationNotifier < Notifier::Base
6
6
 
7
7
  def conditions(observable, observer) ; observer.observable? ; end
8
- def conditions_many(observable, observers) ; observers.send(AssociationObservers::fetch_model_from_collection).observable? ; end
8
+ def conditions_many(observable, observers) ; AssociationObservers::orm_adapter.collection_class(observers).observable? ; end
9
9
 
10
10
  # propagates the message to the observer's observer if the
11
11
  # observer is indeed observed by any entity
12
12
  def action(observable, observer, callback=@callback)
13
- (observer.send(AssociationObservers::check_new_record_method) or not observer.respond_to?(:delay)) ? observer.send(:notify_observers, callback) : observer.delay.notify_observers(callback)
13
+ observer.send(:notify_observers, [callback, [@options[:observable_association_name] ] ])
14
14
  end
15
15
 
16
16
  end
@@ -0,0 +1,48 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require "association_observers/orm/base"
3
+
4
+ module AssociationObservers
5
+ module Orm
6
+ class ActiveRecord < Base
7
+ def self.find(klass, primary_key)
8
+ klass.find_by_id(primary_key)
9
+ end
10
+
11
+ # @see AssociationObservers::Orm::Base.find_all
12
+ def self.find_all(klass, attributes)
13
+ klass.send("find_all_by_#{attributes.keys.join('_and_')}", *attributes.values)
14
+ end
15
+
16
+ # @see AssociationObservers::Orm::Base.get_field
17
+ def self.get_field(collection, attrs={})
18
+ collection.is_a?(::ActiveRecord::Relation) or collection.respond_to?(:proxy_association) ?
19
+ collection.limit(attrs[:limit]).offset(attrs[:offset]).pluck(*attrs[:fields].map{|attr| "#{collection_class(collection).arel_table.name}.#{attr}" }) :
20
+ super
21
+ end
22
+
23
+ # @see AssociationObservers::Orm::Base.collection_class
24
+ def self.collection_class(collection)
25
+ collection.klass
26
+ end
27
+
28
+ # @see AssociationObservers::Orm::Base.class_variable_set
29
+ def self.class_variable_set(klass, name)
30
+ klass.cattr_accessor name
31
+ end
32
+
33
+ # @see AssociationObservers::Orm::Base.batched_each
34
+ def self.batched_each(collection, batch, &block)
35
+ if collection.is_a?(::ActiveRecord::Relation) ?
36
+ collection.find_each(:batch_size => batch, &block) :
37
+ super
38
+ end
39
+ end
40
+
41
+ # @see AssociationObservers::Orm::Base.validate_parameters
42
+ def self.validate_parameters(observer, observable_associations, notifier_names, callbacks)
43
+ raise "Invalid callback; possible options: :create, :update, :save, :destroy" unless callbacks.all?{|o|[:create,:update,:save,:destroy].include?(o.to_sym)}
44
+ end
45
+ end
46
+ end
47
+ end
48
+
@@ -0,0 +1,61 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module AssociationObservers
3
+ module Orm
4
+ class Base
5
+ # finds record by primary key
6
+ # @abstract
7
+ #
8
+ # @param [Class] klass the class of the record to look for
9
+ # @param [Object] primary_key primary key of the record to look for
10
+ # @return [Symbol] ORM class method that fetches records from the DB
11
+ def self.find(klass, primary_key)
12
+ raise "should be defined in an adapter for the used ORM"
13
+ end
14
+
15
+ # finds all records which match the given attributes
16
+ # @abstract
17
+ #
18
+ # @param [Class] klass the class of the records to look for
19
+ # @param [Hash] attributes list of key/value associations which have to be matched by the found records
20
+ # @return [Symbol] ORM class method that fetches records from the DB
21
+ def self.find_all(klass, attributes)
22
+ raise "should be defined in an adapter for the used ORM"
23
+ end
24
+
25
+ # @param [Array] collection records to iterate through
26
+ # @param [Array] attrs attributes to fetch for
27
+ # @return [Array] a collection of the corresponding values to the given keys for each record
28
+ def self.get_field(collection, attrs)
29
+ attrs[:offset] == 0 ? collection.map{|elem| elem.send(*attrs[:fields])} : []
30
+ end
31
+
32
+ # @abstract
33
+ # @param [Array] collection objects container
34
+ # @return [Symbol] ORM collection method name to get the model of its children
35
+ def self.collection_class(collection)
36
+ raise "should be defined in an adapter for the used ORM"
37
+ end
38
+
39
+ # implementation of a batched each enumerator on a collection
40
+ #
41
+ # @param [Array] collection records to batch through
42
+ def self.batched_each(collection, batch=1, &block)
43
+ batch > 1 ?
44
+ collection.each_slice(batch) { |batch| batch.each(&block) } :
45
+ collection.each(&block)
46
+ end
47
+
48
+ # @abstract
49
+ # checks the parameters received by the observer DSL call, handles unexpected input according by triggering exceptions,
50
+ # warnings, deprecation messages
51
+ # @param [Class] observer the observer class
52
+ # @param [Array] observable_associations collection of the names of associations on the observer which will be observed
53
+ # @param [Array] notifier_classes collection of the notifiers for the observation
54
+ # @param [Array] observer_callbacks collection of the callbacks/methods to be observed
55
+ def self.validate_parameters(observer, observable_associations, notifier_classes, observer_callbacks)
56
+ raise "should be defined in an adapter for the used ORM"
57
+ end
58
+
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,61 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require "association_observers/orm/base"
3
+
4
+ module AssociationObservers
5
+ module Orm
6
+ class DataMapper < Base
7
+ def self.find(klass, primary_key)
8
+ klass.get(primary_key)
9
+ end
10
+
11
+ # @see AssociationObservers::Orm::Base.find_all
12
+ def self.find_all(klass, attributes)
13
+ klass.all(attributes)
14
+ end
15
+
16
+ # @see AssociationObservers::Orm::Base.get_field
17
+ def self.get_field(collection, attrs={})
18
+ collection.is_a?(::DataMapper::Collection) ?
19
+ collection.all(attrs) :
20
+ super
21
+ end
22
+
23
+ # @see AssociationObservers::Orm::Base.collection_class
24
+ def self.collection_class(collection)
25
+ collection.model
26
+ end
27
+
28
+ # @see AssociationObservers::Orm::Base.class_variable_set
29
+ def self.class_variable_set(klass, name)
30
+ klass.instance_eval <<-END
31
+ @@#{name}=nil
32
+ def #{name}
33
+ @@#{name}
34
+ end
35
+
36
+ def #{name}=(value)
37
+ @@#{name} = value
38
+ end
39
+ END
40
+
41
+ end
42
+
43
+ # @see AssociationObservers::Orm::Base.batched_each
44
+ def self.batched_each(collection, batch, &block)
45
+ collection.is_a?(::DataMapper::Collection) ?
46
+ collection.each(&block) : # datamapper batches already by 500 https://groups.google.com/forum/?fromgroups=#!searchin/datamapper/batches/datamapper/lAZWFN4TWAA/G1Gu-ams_QMJ
47
+ super
48
+ end
49
+
50
+ # @see AssociationObservers::Orm::Base.validate_parameters
51
+ def self.validate_parameters(observer, observable_associations, notifier_names, callbacks)
52
+ observable_associations.each do |o|
53
+ if observer.relationships[o].is_a?(::DataMapper::Associations::ManyToMany::Relationship)
54
+ warn "this gem does not currently support observation behaviour for many to many relationships"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
@@ -0,0 +1,71 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'singleton'
3
+
4
+ # the queue handles the notification distributions which the notifiers trigger. The Notifier usually knows what to notify
5
+ # and whom to notify. It passes this information to the queue, which strategizes the dispatching. It is more than a singleton;
6
+ # it is designed as a single inter-process object. Why? Because one cannot marshall procedures, and this unique inter-process
7
+ # object acts as a container for the procedures which will be handled assynchronously by the message queue solution. It is also
8
+ # a proxy to the used background queue solution: the queueing basically proxies the queueing somewhere else (Delayed Job, Resque...)
9
+ module AssociationObservers
10
+ class Queue
11
+ include Singleton
12
+
13
+
14
+ # encapsulates enqueuing strategy. if the callback is to a destroy action, one cannot afford to enqueue, because the
15
+ # observable will be deleted by then. So, perform destroy notifications synchronously right away. If not, the strategy
16
+ # for now is get the object ids and enqueue them with the notifier.
17
+ #
18
+ # @param [ActiveRecord:Relation, DataMapper::Relationship] observers to be notified
19
+ # @param [Notifier::Base] notifier encapsulates the notification logic
20
+ # @param [Hash] opts other possible options that can't be inferred from the given arguments
21
+ def enqueue_notifications(observers, observable, notifier, opts={})
22
+ klass = opts[:klass] || AssociationObservers::orm_adapter.collection_class(observers)
23
+ batch_size = opts[:batch_size] || klass.observable_options[:batch_size]
24
+
25
+ if notifier.callback.eql?(:destroy)
26
+ method = RUBY_VERSION < "1.9" ?
27
+ AssociationObservers::Backports::Proc.fake_curry(notifier.method(:conditional_action).to_proc, observable) :
28
+ notifier.method(:conditional_action).to_proc.curry[observable]
29
+ AssociationObservers::orm_adapter.batched_each(observers, batch_size, &method)
30
+ else
31
+ # create workers
32
+ i = 0
33
+ loop do
34
+ ids = AssociationObservers::orm_adapter.get_field(observers, :fields => [:id], :limit => batch_size, :offset => i*batch_size).compact
35
+ break if ids.empty?
36
+ enqueue(Workers::ManyDelayedNotification, ids, klass.name, observable.id, observable.class.name, notifier)
37
+ i += 1
38
+ end
39
+ end
40
+ end
41
+
42
+ def engine=(engine)
43
+ AssociationObservers::options[:queue][:engine] = engine
44
+ initialize_queue_engine
45
+ end
46
+
47
+ def initialize_queue_engine
48
+ engine = AssociationObservers::options[:queue][:engine]
49
+ return if engine.nil?
50
+ raise "#{engine}: unsupported engine" unless %w(delayed_job resque sidekiq).include?(engine.to_s)
51
+ # first, remove stuff from previous engine
52
+ # TODO: can une exclude modules???
53
+ #if AssociationObservers::options[:queue_engine]
54
+ #
55
+ #end
56
+ require "association_observers/extensions/#{engine}"
57
+ end
58
+
59
+ private
60
+
61
+ # enqueues the task with the given arguments to be processed asynchronously
62
+ # this method implements the fallback, which is: execute synchronously
63
+ # @note this method is overwritten by the message queue adapters. If your background queue engine is not supported,
64
+ # overwrite this method and delegate to your background queue.
65
+ def enqueue(task, *args)
66
+ t = task.new(*args)
67
+ t.perform
68
+ end
69
+
70
+ end
71
+ end
@@ -2,18 +2,25 @@
2
2
  module AssociationObservers
3
3
  def self.initialize_railtie
4
4
  ActiveSupport.on_load :active_record do
5
- require 'association_observers/activerecord'
6
- end
7
- ActiveSupport.on_load :data_mapper do
8
- require 'association_observers/datamapper'
5
+ require 'association_observers/active_record'
9
6
  end
7
+ # ORM Adapters
8
+ require 'association_observers/data_mapper' if defined?(DataMapper)
9
+
10
10
  end
11
11
  class Railtie < Rails::Railtie
12
+ config.association_observers = ActiveSupport::OrderedOptions.new.merge(AssociationObservers::options)
13
+ config.association_observers.queue = ActiveSupport::OrderedOptions.new.merge(config.association_observers.queue)
14
+
12
15
  initializer 'association_observers.insert_into_orm' do
13
16
  AssociationObservers.initialize_railtie
14
17
  end
15
18
  initializer 'association_observers.autoload', :before => :set_autoload_paths do |app|
16
19
  app.config.autoload_paths << Rails.root.join("app", "notifiers")
17
20
  end
21
+ initializer 'association_observers.rails_configuration_options' do
22
+ AssociationObservers::options.merge!(config.association_observers)
23
+ AssociationObservers::queue.initialize_queue_engine
24
+ end
18
25
  end
19
26
  end