reactive_observers 0.0.1.pre

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6be66bbad98da7ba75c596f536c7d20eb8abf6b376c3d53db9a2c8e2fe925128
4
+ data.tar.gz: 13a0090307196c9295bee4640fd57a938276063c5c4c7a508f3d7dee46694400
5
+ SHA512:
6
+ metadata.gz: 46689eaebf879db1bb5c256405383f13bc406295abce70932d1b1328914fe6c8b09907dc08b7ae57f6e70dd7990035b4b2dab3d6754b62eac8e3c3d035b071a3
7
+ data.tar.gz: 11bb559400d8313adf10754cc409e2c9453612e4b11c469e52c0af7f865c551aa948b1953b8f661c095332158a1c18a5f7d8e5625c6da4d6107416f0b20c89dc
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ .idea
10
+ /reactive_observers-*.*.*.gem
11
+ /Gemfile.lock
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ reactive_observers
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-2.6.3
data/.travis.yml ADDED
@@ -0,0 +1,16 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+ sudo: required
5
+ rvm:
6
+ - 2.6.3
7
+ services:
8
+ - postgresql
9
+ addons:
10
+ postgresql: 9.6
11
+ before_script:
12
+ - sudo apt-get -qq update
13
+ - sudo apt-get install -y postgresql-9.6-postgis-2.4
14
+ - psql -U postgres -c 'create database test'
15
+ - psql -U postgres -d test -c 'create extension postgis'
16
+ before_install: gem install bundler -v 2.1.0.pre.3
data/Gemfile ADDED
@@ -0,0 +1,18 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in reactive_observers.gemspec
4
+ gemspec
5
+
6
+ gem "rake", "~> 12.0"
7
+
8
+ group :test do
9
+ gem "minitest", "~> 5.0"
10
+ gem 'minitest-reporters'
11
+ gem 'minitest-stub_any_instance'
12
+ gem 'simplecov'
13
+ gem 'codecov'
14
+
15
+ gem 'pg'
16
+ gem 'activerecord-postgis-adapter'
17
+ gem 'sqlite3'
18
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 martintomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # ReactiveObservers
2
+
3
+ [![Build Status](https://travis-ci.com/martintomas/reactive_observers.svg?branch=master)](https://travis-ci.com/martintomas/reactive_observers)
4
+ [![codecov](https://codecov.io/gh/martintomas/reactive_observers/branch/master/graph/badge.svg)](https://codecov.io/gh/martintomas/reactive_observers)
5
+
6
+ This gem can make observer from every possible class or object. Observer relation can be defined at Class level and processed dynamically when appropriate record changes. Observable module is using build in Active Record hooks or database triggers which can be turned on for specified tables in multiple App environment.
7
+
8
+ ```ruby
9
+ class Topic < ActiveRecord::Base; end
10
+ class CustomObserver
11
+ include ReactiveObservers::Base
12
+
13
+ def changed(topic, **observer); end
14
+ end
15
+
16
+ # possible usage of observer
17
+ # when observer is defined at class level, observer object is initialized when observed record changes
18
+ CustomObserver.observe(:topics) # observer klass is observing Topic (Active Record klass)
19
+ CustomObserver.observe(Topic.first) # observer is observing specific topic
20
+ CustomObserver.new.observe(:topics) # specific observer is observing Topic
21
+ CustomObserver.new.observe(Topic.first) # specific observer is observing specific topic
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem 'reactive_observers'
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ $ bundle install
35
+
36
+ Or install it yourself as:
37
+
38
+ $ gem install reactive_observers
39
+
40
+ ## Usage
41
+
42
+ Observing relation can be initialized between object/class pairs. Observer can be any class or object. Observable has to be always Active Record class or object. It is recommended to define class observers as often as possible, because It enables to observe all active records independently and initialize all required data after observer is triggered.
43
+
44
+ Every class can be transformed to observer just by including following module:
45
+
46
+ $ include ReactiveObservers::Base
47
+
48
+ Class has access to `observe` method now and can observe any active record object or class. Registering of observer can be done for example by:
49
+
50
+ $ YourClass.observe(ActiveRecord::Base)
51
+
52
+ Observe method accepts several different arguments which are:
53
+ * filtering options
54
+ * `on` - observer is notified only when specific types of action happens, for example: `on: [:create, :update]`
55
+ * `fields` - observer is notified only when specified active record attributes are changed, for example: `fields: [:first_name, :last_name]`
56
+ * `only` - accepts Proc and can do additional complex filtering, for example: `only: ->(active_record) { active_record.type == 'ObservableType' }`
57
+ * active options
58
+ * `trigger` - can be symbol or Proc and defines action which is called on observer, for example: `trigger: :recompute_statistics`. Default value, which can be changed through configuration, is `:changed`.
59
+ * `notify` - can be Symbol or Proc and defines action which is used to initialize observes class, for example: `notify: :load_all_dependent_objects`
60
+ * `refine` - accepts Proc and defines operation which is done with active record object before observer is called, for example: `refine: ->(active_record) { active_record.topics }`
61
+ * additional options
62
+ * `context` - observer can be registered with context information which is provided back from observed object when notification happens. Example of such option can be for example: `context: :topic`
63
+
64
+ ### Active Record as Observer
65
+
66
+ Every Active Record class can be transformed to observer - It can observe and be observed at same time. Let's have following example:
67
+
68
+ ```ruby
69
+ class Comment < ActiveRecord::Base; end
70
+ class Topic < ActiveRecord::Base
71
+ include ReactiveObservers::Base
72
+
73
+ # register observer for comments
74
+ # when Comment is created, call update_topic_dashboard of Topic
75
+ # notify param tells observed objects which topic should be called
76
+ observe :comments, on: :create, trigger: :update_topic_dashboard, notify: ->(comment) { comment.topic }
77
+
78
+ def update_topic_dashboard(**observer); end
79
+ end
80
+ ```
81
+
82
+ ### Observer as part of any class
83
+
84
+ Every possible class can be transformed to observer. Let's define simple class as our first example:
85
+
86
+ ```ruby
87
+ class TopicsStatisticService
88
+ include ReactiveObservers::Base
89
+
90
+ observe :topics, fields: :active_users, trigger: :recompute_statistics
91
+ observe :comments, on: :create, trigger: :recompute_statistics, refine: ->(comment) { comment.topic }
92
+
93
+ # you can recompute statistics for topic which has changed
94
+ def recompute_statistics(topic, **observer); end
95
+ end
96
+ ```
97
+
98
+ Class observers can become a bit tricky when initialization method is defined inside observer.
99
+
100
+ ```ruby
101
+ class TopicStatisticService
102
+ include ReactiveObservers::Base
103
+
104
+ observe :topics, fields: :active_users, trigger: :recompute_statistics, only: -> (topic) { topic.active? },
105
+ notify: ->(topic) { TopicStatisticService.new(topic, topic.active_comments) }
106
+
107
+ # observed object will use notify param to instanciate TopicStatisticService object
108
+ # this param is required because initialize requires additional arguments
109
+ def initialize(topic, active_comments); end
110
+
111
+ def recompute_statistics(topic, **observer); end
112
+ end
113
+ ```
114
+
115
+ Initialize method of class can quickly get out of the hand and It can be impossible to initialize it only with observed object information. Fortunately, observing can be defined at object level which doesn't require initialization.
116
+
117
+ ```ruby
118
+ class ComplexStatisticsService
119
+ include ReactiveObservers::Base
120
+
121
+ # both params are unknown and It is impossible to initialize ComplexStatisticsService with observed data
122
+ def initialize(unknown_param1, unknown_param2); end
123
+
124
+ def changed(record, **observer); end
125
+ end
126
+
127
+ # Class.observe pattern cannot be used in this case, because observed record cannot probably instantiate this service.
128
+ # You can register specific service as observer
129
+ ComplexStatisticsService.new(param1, param2).observe(:topics) # all topics will be observed
130
+ ComplexStatisticsService.new(param1, param2).observe(Topic.first) # only first topic will be observed
131
+ ```
132
+
133
+ ### Observer Class
134
+
135
+ Implementation of specific observers can be encapsulated into one class which makes future maintenance quite simple - in reality, this is preferred way how to define observers. Quite dummy example of such observer class can be:
136
+
137
+ ```ruby
138
+ # Example of Activity Observer
139
+ # observe appropriate record and recompute topic activity when data changes
140
+ class ActivityObserver
141
+ include ReactiveObservers::Base
142
+
143
+ observe :topics, fields: :last_activity
144
+ observe :comments, refine: ->(comment) { comment.topic }
145
+ observe :users, fields: :open_topic, refine: ->(user) { user.open_topic }
146
+ observe :images, on: :create, trigger: :image_uploaded
147
+
148
+ # activity data at topic changed, recompute it
149
+ def changed(topic, **observer); end
150
+
151
+ # image upload requires specifies approach, use special trigger for it
152
+ def image_uploaded(image, **observer); end
153
+ end
154
+ ```
155
+
156
+ ### Remove Observer
157
+
158
+ It is possible to remove observers from observed class or object at any time.
159
+
160
+ ```ruby
161
+ Topic.remove_observer(ActivityObserver) # remove activity observer from Topic class
162
+ Topic.first.remove_observer(ActivityObserver) # remove observer from first topic
163
+ Topic.remove_observer(observing_service) # observer can be also object and this observer can be removed same way as class observer
164
+
165
+ # remove_observer method accepts additional arguments that specifies which observers should be removed
166
+ Topic.remove_observer(ActivityObserver, trigger: :image_uploaded) # only observer with appropriate trigger will be removed
167
+ Topic.remove_observer(ActivityObserver, trigger: :image_uploaded, on: [:create, :update])
168
+ Topic.remove_observer(ActivityObserver, fields: [:first_name, :last_name])
169
+ Topic.remove_observer(ActivityObserver, notify: :prepare_observer)
170
+ Topic.remove_observer(ActivityObserver, context: :topic) # only observer with appropriate context will be removed
171
+ ```
172
+
173
+ ### Database Triggers (Advanced)
174
+
175
+ Observed data can be sometimes manipulated by several different sources - for example different apps can update it. Unfortunately, active record hooks in our Rails App cannot catch such change - which can cause that observers are not notified. For this purpose, this gem supports observers which use database triggers.
176
+
177
+ Only __PostgreSQL database__ is supported now but You are welcomed to add other database adapters!
178
+
179
+ To enable database observers, following configuration needs to be put into initializers:
180
+
181
+ ReactiveObservers.configure do |config|
182
+ config.observed_tables = [:topics] # names of tables which should be observed
183
+ end
184
+
185
+ It is also required to create appropriate database triggers. __TODO:__ prepare jobs that generates appropriate triggers for defined tables.
186
+
187
+ Gem listens on `TG_TABLE_NAME_notices` which for example means that observer for topic table will listen on `topic_notices`. This default behaviour can be changed at configuration:
188
+
189
+ $ ReactiveObservers.configure { |config| config.listening_job_name = "%{table_name}_notices" }
190
+
191
+ Data obtained through trigger are forwarded to observers, but can be also used for different purposes (not just observing). It is possible to register any method that can process trigger data at any active record model:
192
+
193
+ ```ruby
194
+ class Topic < ActiveRecord::Base
195
+ register_observer_listener :process_trigger
196
+
197
+ def self.process_trigger(data); end
198
+ end
199
+ ```
200
+
201
+ ## Contributing
202
+
203
+ Bug reports and pull requests are welcome on GitHub at https://github.com/martintomas/reactive_observers. This project is intended to be a safe and welcoming space for collaboration.
204
+
205
+ ## License
206
+
207
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
208
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "reactive_observers"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'reactive_observers/observer/container'
5
+
6
+ module ReactiveObservers
7
+ # add observe methods to appropriate class
8
+ #
9
+ # class CustomObserver
10
+ # include ReactiveObservers::Base
11
+ #
12
+ # def changed(topic, **observer); end
13
+ # end
14
+ #
15
+ module Base
16
+ extend ActiveSupport::Concern
17
+
18
+ class_methods do
19
+ # create class observer for provided active record object or class
20
+ #
21
+ # CustomObserver.observe(:topics) # observer is observing Topic klass
22
+ # CustomObserver.observe(Topic.first) # observer is observing specific topic
23
+ #
24
+ # @param observed [Symbol, Object] observed object or symbol defining specific ActiveRecord class
25
+ # @param refine [Proc] lambda or Proc function defining observed object pre-process before It is sent to observer
26
+ # @param trigger [Proc, Symbol] function that is triggered inside observer during notification
27
+ # @param notify [Proc, Symbol] function that is used to initialize appropriate observer objects
28
+ # @param options [Hash] additional arguments such as: on, only, fields or context
29
+ # @return [ReactiveObservers::Observer::Container] observer
30
+ def observe(observed, refine: nil, trigger: ReactiveObservers.configuration.default_trigger, notify: nil, **options)
31
+ add_observer_to_observable self, observed, options.merge(refine: refine, trigger: trigger, notify: notify)
32
+ end
33
+
34
+ # register observer at observed entity
35
+ # @param observer [Class, Object]
36
+ # @param observed [Symbol, Object]
37
+ # @param options [Hash]
38
+ # @return [ReactiveObservers::Observer::Container] observer
39
+ def add_observer_to_observable(observer, observed, options)
40
+ ReactiveObservers::Observer::Container.new(observer, observed, options).tap do |observer_container|
41
+ observer_container.observed_klass.register_observer observer_container
42
+ end
43
+ end
44
+ end
45
+
46
+ # create object observer for provided active record object or class
47
+ #
48
+ # CustomObserver.new.observe(:topics) # observer is observing Topic klass
49
+ # CustomObserver.new.observe(Topic.first) # observer is observing specific topic
50
+ #
51
+ # @param observed [Symbol, Object] observed object or symbol defining specific ActiveRecord class
52
+ # @param refine [Proc] lambda or Proc function defining observed object pre-process before It is sent to observer
53
+ # @param trigger [Proc, Symbol] function that is triggered inside observer during notification
54
+ # @param notify [Proc, Symbol] function that is used to initialize appropriate observer objects
55
+ # @param options [Hash] additional arguments such as: on, only, fields or context
56
+ # @return [ReactiveObservers::Observer::Container] observer
57
+ def observe(observed, refine: nil, trigger: ReactiveObservers.configuration.default_trigger, notify: nil, **options)
58
+ self.class.add_observer_to_observable self, observed, options.merge(refine: refine, trigger: trigger, notify: notify)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ class Configuration
5
+
6
+ attr_accessor :listening_job_name, :observed_tables, :default_trigger
7
+
8
+ def initialize
9
+ reset!
10
+ end
11
+
12
+ def reset!
13
+ @listening_job_name = "%{table_name}_notices" # trigger listens for these type of notices
14
+ @observed_tables = [] # these tables are observed at database level
15
+ @default_trigger = :changed # default name of method that is called inside observer during notification
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ module DatabaseAdapters
5
+ class AbstractAdapter
6
+ def initialize(configuration, klasses)
7
+ @configuration = configuration
8
+ @klasses = klasses
9
+ end
10
+
11
+ def start_listening
12
+ @klasses.each { |klass| create_listening_job_for klass }
13
+ end
14
+
15
+ def stop_listening
16
+ @klasses.each { |klass| stop_listening_job_for klass }
17
+ end
18
+
19
+ private
20
+
21
+ def create_listening_job_for(klass)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def stop_listening_job_for(klass)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def process_notification_for(data, klass)
30
+ klass.observer_listener_services.each { |service| klass.method(service).call data }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_observers/database_adapters/postgresql_adapter'
4
+
5
+ module ReactiveObservers
6
+ module DatabaseAdapters
7
+ class Factory
8
+ def initialize(configuration)
9
+ @configuration = configuration
10
+ end
11
+
12
+ def initialize_observer_listeners
13
+ collect_database_adapters.each do |database_adapter, klasses|
14
+ case database_adapter
15
+ when 'PostgreSQL'
16
+ PostgreSQLAdapter.new(@configuration, klasses).start_listening
17
+ when 'PostGIS'
18
+ PostgreSQLAdapter.new(@configuration, klasses).start_listening
19
+ else
20
+ raise StandardError, "Reactive observers cannot be run with this database adapter: #{database_adapter}!"
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def collect_database_adapters
28
+ {}.tap do |result|
29
+ @configuration.observed_tables.map do |observed_table|
30
+ klass = observed_table.to_s.classify.constantize
31
+ adapter = klass.connection.adapter_name
32
+ result[adapter] = (result[adapter] || []) << klass
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_observers/database_adapters/abstract_adapter'
4
+
5
+ module ReactiveObservers
6
+ module DatabaseAdapters
7
+ class PostgreSQLAdapter < AbstractAdapter
8
+
9
+ private
10
+
11
+ def create_listening_job_for(klass)
12
+ Thread.new do
13
+ klass.connection.execute "LISTEN #{ @configuration.listening_job_name % { table_name: klass.table_name }}"
14
+ loop do
15
+ klass.connection.raw_connection.wait_for_notify do |event, pid, payload|
16
+ data = JSON.parse payload, symbolize_names: true
17
+ process_notification_for data, klass
18
+ end
19
+ end
20
+ end.abort_on_exception = true
21
+ end
22
+
23
+ def stop_listening_job_for(klass)
24
+ klass.connection.execute "UNLISTEN #{ @configuration.listening_job_name % { table_name: klass.table_name }}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_observers/observable/db_listener'
4
+ require 'reactive_observers/observable/notification'
5
+ require 'reactive_observers/observable/removing'
6
+
7
+ require 'active_support/concern'
8
+
9
+ module ReactiveObservers
10
+ module Observable
11
+ # enables class to be observable
12
+ # automatically included to ActiveRecord::Base
13
+ module Base
14
+ extend ActiveSupport::Concern
15
+ include Observable::DbListener
16
+
17
+ included do
18
+ class_attribute :active_observers
19
+ self.active_observers = []
20
+ register_observer_listener :process_observer_notification
21
+
22
+ after_create do
23
+ process_observer_hook_notification :create
24
+ end
25
+
26
+ after_update do
27
+ process_observer_hook_notification :update, diff: previous_changes.each_with_object({}) { |(k, v), r| r[k] = v.first }
28
+ end
29
+
30
+ after_destroy do
31
+ process_observer_hook_notification :destroy
32
+ end
33
+ end
34
+
35
+ class_methods do
36
+ # register observer to this class
37
+ # @param observer [ReactiveObservers::Observer::Container]
38
+ # @return [Array] active observers
39
+ def register_observer(observer)
40
+ return if active_observers.any? { |active_observer| active_observer.compare.full? observer }
41
+
42
+ active_observers << observer
43
+ end
44
+
45
+ # remove observer for specific object
46
+ #
47
+ # Topic.remove_observer(ActivityObserver) # remove observer from Topic
48
+ # Topic.remove_observer(observing_service) # removed observer can be also object
49
+ #
50
+ # @param observer [Class, Object] observer that should be removed
51
+ # @param options [Hash] additional options that specifies which observers should be removed
52
+ # @return [Array] still active observers
53
+ def remove_observer(observer, **options)
54
+ Observable::Removing.new(active_observers, observer, options).perform
55
+ end
56
+
57
+ # process notification from db trigger
58
+ # @param data [Hash] data obtain from db trigger
59
+ def process_observer_notification(data)
60
+ return if active_observers.blank?
61
+
62
+ if data[:action] == 'INSERT'
63
+ find(data[:id]).process_observer_notifications :create
64
+ elsif data[:action] == 'UPDATE'
65
+ find(data[:id]).process_observer_notifications :update, diff: data[:diff]
66
+ elsif data[:action] == 'DELETE'
67
+ new(data[:diff]).process_observer_notifications :destroy
68
+ else
69
+ raise StandardError, "Notification from db returned unknown action: #{data[:action]}"
70
+ end
71
+ end
72
+ end
73
+
74
+ # remove observer for specific object
75
+ #
76
+ # Topic.first.remove_observer(ActivityObserver) # remove observer from first topic
77
+ #
78
+ # @param observer [Class, Object] observer that should be removed
79
+ # @param options [Hash] additional options that specifies which observers should be removed
80
+ # @return [Array] still active observers
81
+ def remove_observer(observer, **options)
82
+ self.class.remove_observer observer, options.merge(constrain: [id])
83
+ end
84
+
85
+ # process notification from ActiveRecord hooks
86
+ # @param action [Symbol]
87
+ # @param options [Hash]
88
+ def process_observer_hook_notification(action, **options)
89
+ return if ReactiveObservers.configuration.observed_tables.include?(self.class.table_name.to_sym) || self.class.active_observers.blank?
90
+
91
+ process_observer_notifications action, **options
92
+ end
93
+
94
+ # process observer notification
95
+ # @param action [Symbol]
96
+ # @param options [Hash]
97
+ def process_observer_notifications(action, **options)
98
+ Observable::Notification.new(self, self.class.active_observers, action, options).perform
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module ReactiveObservers
6
+ module Observable
7
+ module DbListener
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :observer_listener_services
12
+ self.observer_listener_services = []
13
+ end
14
+
15
+ class_methods do
16
+ def register_observer_listener(method_name)
17
+ observer_listener_services << method_name
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ module Observable
5
+ class Filtering
6
+ def initialize(observed_object_id, observers, action, options)
7
+ @observed_object_id = observed_object_id
8
+ @observers = observers
9
+ @action = action
10
+ @options = options
11
+ end
12
+
13
+ def perform
14
+ @observers.select do |observer|
15
+ filter_action(observer) && filter_record_constrains(observer) && filter_fields(observer)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def filter_action(observer)
22
+ observer.on.blank? || observer.on.include?(@action)
23
+ end
24
+
25
+ def filter_record_constrains(observer)
26
+ observer.constrain.blank? || observer.constrain.include?(@observed_object_id)
27
+ end
28
+
29
+ def filter_fields(observer)
30
+ return true unless @action == :update && @options[:diff].present?
31
+
32
+ observer.fields.blank? || (observer.fields & changed_fields).length.positive?
33
+ end
34
+
35
+ def changed_fields
36
+ @changed_fields ||= @options[:diff].keys.map &:to_sym
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_observers/observable/filtering'
4
+
5
+ module ReactiveObservers
6
+ module Observable
7
+ class Notification
8
+ def initialize(observed_object, observers, action, options)
9
+ @observed_object = observed_object
10
+ @observers = observers
11
+ @action = action
12
+ @options = options
13
+ end
14
+
15
+ def perform
16
+ filter_observers.each do |observer|
17
+ next if observer.only.present? && !observer.only.call(@observed_object)
18
+
19
+ trigger_actions_for observer, Array.wrap(refine_observer_records_for(observer))
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def filter_observers
26
+ @filtered_observers ||= Filtering.new(@observed_object.id, @observers, @action, @options).perform
27
+ end
28
+
29
+ def trigger_actions_for(observer, records)
30
+ records.each do |record|
31
+ Array.wrap(observer_objects_for(observer, record)).each do |observer_object|
32
+ observer_object = observer_simplification?(observer, observer_object, record) ? record : observer_object
33
+ trigger_observer_action_for observer, observer_object, record
34
+ end
35
+ end
36
+ end
37
+
38
+ def trigger_observer_action_for(observer, observer_object, record)
39
+ trigger = build_proc_for observer.trigger, observer_object
40
+ return trigger.call observer.to_h if observer_object == record
41
+
42
+ trigger.call record, observer.to_h
43
+ end
44
+
45
+ def refine_observer_records_for(observer)
46
+ return @observed_object if observer.refine.blank?
47
+
48
+ observer.refine.call @observed_object
49
+ end
50
+
51
+ def observer_objects_for(observer, record)
52
+ return observer_objects_from_klass observer, record if observer.klass_observer?
53
+ return build_proc_for(observer.notify, observer.observer).call(observer.observer, record) if observer.notify.present?
54
+
55
+ observer.observer
56
+ end
57
+
58
+ def observer_objects_from_klass(observer, record)
59
+ return build_proc_for(observer.notify, observer.observer).call(record) if observer.notify.present?
60
+
61
+ observer.observer.new
62
+ end
63
+
64
+ def build_proc_for(variable, object)
65
+ return variable unless variable.is_a? Symbol
66
+
67
+ object.method variable
68
+ end
69
+
70
+ def observer_simplification?(observer, record, observer_object)
71
+ (observer_object == record) || (observer.klass_observer? && observer_object.is_a?(record.class))
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ module Observable
5
+ class Removing
6
+ REQUIRED_FIELDS = %i[klass object].freeze
7
+
8
+ def initialize(active_observers, observer, removing_options)
9
+ @active_observers = active_observers
10
+ @observer = observer
11
+ @removing_options = removing_options
12
+ end
13
+
14
+ def perform
15
+ @active_observers.delete_if do |active_observer|
16
+ active_observer.compare.partial? @observer, @removing_options
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reactive_observers/observer/container_validator'
4
+ require 'reactive_observers/observer/container_comparator'
5
+
6
+ module ReactiveObservers
7
+ module Observer
8
+ class Container
9
+
10
+ attr_accessor :observer, :observed
11
+ attr_accessor :trigger, :notify, :refine, :context
12
+ attr_accessor :fields, :on, :only, :constrain
13
+
14
+ def initialize(observer, observed, options)
15
+ @observer = observer
16
+ @observed = observed.is_a?(Symbol) ? observed.to_s.classify.constantize : observed
17
+ @on = Array.wrap options[:on]
18
+ @fields = Array.wrap options[:fields]
19
+ @only = options[:only]
20
+ @trigger = options[:trigger] || ReactiveObservers.configuration.default_trigger
21
+ @notify = options[:notify]
22
+ @refine = options[:refine]
23
+ @context = options[:context]
24
+ ReactiveObservers::Observer::ContainerValidator.new(self).run_validations!
25
+ @constrain = load_observer_constrains
26
+ end
27
+
28
+ def compare
29
+ ReactiveObservers::Observer::ContainerComparator.new(self)
30
+ end
31
+
32
+ def klass_observer?
33
+ @observer.is_a? Class
34
+ end
35
+
36
+ def klass_observed?
37
+ @observed.is_a? Class
38
+ end
39
+
40
+ def observer_klass
41
+ return @observer if klass_observer?
42
+
43
+ @observer.class
44
+ end
45
+
46
+ def observed_klass
47
+ return @observed if klass_observed?
48
+
49
+ @observed.class
50
+ end
51
+
52
+ def to_h
53
+ { observer: @observer, observed: @observed, fields: @fields, on: @on, only: @only, constrain: @constrain,
54
+ trigger: @trigger, refine: @refine, notify: @notify, context: @context}
55
+ end
56
+
57
+ private
58
+
59
+ def load_observer_constrains
60
+ return [] if @observed.is_a?(Class)
61
+
62
+ [@observed.id]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ module Observer
5
+ class ContainerComparator
6
+ def initialize(observer)
7
+ @observer = observer
8
+ end
9
+
10
+ def partial?(observer, options)
11
+ @observer.observer == observer &&
12
+ array_compare_of(@observer.fields, options[:fields]) &&
13
+ array_compare_of(@observer.on, options[:on]) &&
14
+ context_compare_with(options[:context]) &&
15
+ constrain_compare_with(options[:constrain])
16
+ end
17
+
18
+ def full?(observer)
19
+ partial?(observer.observer, fields: observer.fields, on: observer.on, constrain: observer.constrain) &&
20
+ @observer.observed == observer.observed &&
21
+ @observer.trigger == observer.trigger &&
22
+ @observer.notify == observer.notify &&
23
+ @observer.refine == observer.refine &&
24
+ @observer.only == observer.only
25
+ end
26
+
27
+ private
28
+
29
+ def array_compare_of(argument, option_value)
30
+ argument.blank? || option_value.blank? || (argument & Array.wrap(option_value)).length.positive?
31
+ end
32
+
33
+ def constrain_compare_with(value)
34
+ value = Array.wrap value
35
+ value.blank? || @observer.constrain == value || (@observer.constrain & value).length.positive?
36
+ end
37
+
38
+ def context_compare_with(value)
39
+ value.blank? || @observer.context == value
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ module Observer
5
+ class ContainerValidator
6
+ def initialize(observer)
7
+ @observer = observer
8
+ end
9
+
10
+ def run_validations!
11
+ validate_observe_trigger!
12
+ validate_observe_notification!
13
+ validate_observe_active_record!
14
+ true
15
+ end
16
+
17
+ private
18
+
19
+ def validate_observe_trigger!
20
+ return unless @observer.trigger.is_a?(Symbol) && !@observer.observer_klass.method_defined?(@observer.trigger)
21
+
22
+ raise ArgumentError, "Class #{@observer.observer_klass.name} is missing required observed method #{@observer.trigger}"
23
+ end
24
+
25
+ def validate_observe_notification!
26
+ return if @observer.notify.present? || !@observer.klass_observer?
27
+
28
+ @observer.observer.new
29
+ rescue ArgumentError
30
+ raise ArgumentError, "Notify parameter is required for observer class #{@observer.observer_klass.name} which has complex initialization"
31
+ end
32
+
33
+ def validate_observe_active_record!
34
+ return if (!@observer.klass_observed? && @observer.observed.is_a?(ActiveRecord::Base)) ||
35
+ (@observer.klass_observed? && @observer.observed <= ActiveRecord::Base)
36
+
37
+ raise ArgumentError, "Class #{@observer.observed_klass.name} is not Active Record class"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveObservers
4
+ VERSION = '0.0.1.pre'.freeze
5
+ end
@@ -0,0 +1,23 @@
1
+ require 'reactive_observers/version'
2
+ require 'reactive_observers/configuration'
3
+ require 'reactive_observers/base'
4
+ require 'reactive_observers/observable/base'
5
+ require 'reactive_observers/database_adapters/factory'
6
+
7
+ require 'active_record'
8
+
9
+ module ReactiveObservers
10
+ class Error < StandardError; end
11
+
12
+ mattr_accessor :configuration, default: Configuration.new
13
+
14
+ def self.configure
15
+ self.configuration ||= Configuration.new
16
+ yield(configuration) if block_given?
17
+ DatabaseAdapters::Factory.new(configuration).initialize_observer_listeners
18
+ end
19
+ end
20
+
21
+ class ActiveRecord::Base
22
+ include ReactiveObservers::Observable::Base
23
+ end
@@ -0,0 +1,35 @@
1
+ require_relative 'lib/reactive_observers/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "reactive_observers"
5
+ spec.version = ReactiveObservers::VERSION
6
+ spec.authors = ["martintomas"]
7
+ spec.email = ["tomas@jchsoft.cz"]
8
+
9
+ spec.summary = %q{Observe Active Record classes and records reactive way!}
10
+ spec.description = %q{Every class or object can be transformed to observer and dynamically react to data changes across several models. Observable module is using build in Active Record hooks or database triggers which can be turned on in multiple App environment. }
11
+ spec.homepage = "https://github.com/martintomas/reactive_observers.git"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
14
+
15
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/martintomas/reactive_observers.git"
19
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_dependency "activerecord", ">= 5.0"
31
+
32
+ spec.add_development_dependency "bundler", "~> 2.0"
33
+ spec.add_development_dependency "rake", "~> 10.0"
34
+ spec.add_development_dependency "minitest", "~> 5.0"
35
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reactive_observers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.pre
5
+ platform: ruby
6
+ authors:
7
+ - martintomas
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-11-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ description: 'Every class or object can be transformed to observer and dynamically
70
+ react to data changes across several models. Observable module is using build in
71
+ Active Record hooks or database triggers which can be turned on in multiple App
72
+ environment. '
73
+ email:
74
+ - tomas@jchsoft.cz
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - ".gitignore"
80
+ - ".ruby-gemset"
81
+ - ".ruby-version"
82
+ - ".travis.yml"
83
+ - Gemfile
84
+ - LICENSE.txt
85
+ - README.md
86
+ - Rakefile
87
+ - bin/console
88
+ - bin/setup
89
+ - lib/reactive_observers.rb
90
+ - lib/reactive_observers/base.rb
91
+ - lib/reactive_observers/configuration.rb
92
+ - lib/reactive_observers/database_adapters/abstract_adapter.rb
93
+ - lib/reactive_observers/database_adapters/factory.rb
94
+ - lib/reactive_observers/database_adapters/postgresql_adapter.rb
95
+ - lib/reactive_observers/observable/base.rb
96
+ - lib/reactive_observers/observable/db_listener.rb
97
+ - lib/reactive_observers/observable/filtering.rb
98
+ - lib/reactive_observers/observable/notification.rb
99
+ - lib/reactive_observers/observable/removing.rb
100
+ - lib/reactive_observers/observer/container.rb
101
+ - lib/reactive_observers/observer/container_comparator.rb
102
+ - lib/reactive_observers/observer/container_validator.rb
103
+ - lib/reactive_observers/version.rb
104
+ - reactive_observers.gemspec
105
+ homepage: https://github.com/martintomas/reactive_observers.git
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ homepage_uri: https://github.com/martintomas/reactive_observers.git
110
+ source_code_uri: https://github.com/martintomas/reactive_observers.git
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 2.3.0
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">"
123
+ - !ruby/object:Gem::Version
124
+ version: 1.3.1
125
+ requirements: []
126
+ rubygems_version: 3.0.4
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Observe Active Record classes and records reactive way!
130
+ test_files: []