association_observers 0.0.5 → 0.0.6

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 (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