tantot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ require 'tantot/formatter/compact'
2
+ require 'tantot/formatter/detailed'
3
+
4
+ module Tantot
5
+ module Formatter
6
+ def self.resolve(name)
7
+ "Tantot::Formatter::#{name.to_s.camelize}".safe_constantize or raise "Can't find formatter class `#{name}`"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,84 @@
1
+ require 'cityhash'
2
+
3
+ module Tantot
4
+ module Observe
5
+ module Helpers
6
+ def condition_proc(context)
7
+ attributes = context[:attributes]
8
+ options = context[:options]
9
+ proc do
10
+ has_changes = attributes.any? ? (self.destroyed? || (self._watch_changes.keys & attributes).any?) : true
11
+ has_changes && (!options.key?(:if) || self.instance_exec(&options[:if]))
12
+ end
13
+ end
14
+
15
+ def update_proc(context)
16
+ proc do
17
+ attributes = context[:attributes]
18
+ watched_changes =
19
+ if attributes.any?
20
+ if self.destroyed?
21
+ attributes.each.with_object({}) {|attr, hash| hash[attr] = [self[attr]]}
22
+ else
23
+ self._watch_changes.slice(*attributes)
24
+ end
25
+ else
26
+ self._watch_changes
27
+ end
28
+
29
+ Tantot.collector.push(context, self, watched_changes)
30
+ end
31
+ end
32
+ end
33
+
34
+ extend Helpers
35
+
36
+ module ActiveRecordMethods
37
+ extend ActiveSupport::Concern
38
+
39
+ def _watch_changes
40
+ Tantot.config.use_after_commit_callbacks ? self.previous_changes : self.changes
41
+ end
42
+
43
+ class_methods do
44
+ # watch watcher, :attr, :attr, :attr, option: :value
45
+ # watch :attr, :attr, option: :value, &block
46
+ # watch watcher, option: :value
47
+ # watch option: :value, &block
48
+ def watch(*args, &block)
49
+ options = args.extract_options!
50
+
51
+ watcher = args.first.is_a?(String) || args.first.is_a?(Class) ? Tantot.derive_watcher(args.shift) : nil
52
+ unless !!watcher ^ block_given?
53
+ raise ArgumentError.new("At least one, and only one of `watcher` or `block` can be passed")
54
+ end
55
+
56
+ attributes = args.collect(&:to_s)
57
+
58
+ context = {
59
+ model: self,
60
+ attributes: attributes,
61
+ options: options
62
+ }
63
+
64
+ context[:watcher] = watcher if watcher
65
+ context[:block_id] = CityHash.hash64(block.source_location.collect(&:to_s).join) if block_given?
66
+
67
+ Tantot.collector.register_watch(context, block)
68
+
69
+ callback_options = {
70
+ if: Observe.condition_proc(context)
71
+ }
72
+ update_proc = Observe.update_proc(context)
73
+
74
+ if Tantot.config.use_after_commit_callbacks
75
+ after_commit(callback_options, &update_proc)
76
+ else
77
+ after_save(callback_options, &update_proc)
78
+ after_destroy(callback_options, &update_proc)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,9 @@
1
+ module Tantot
2
+ module Performer
3
+ class Bypass
4
+ def run(context, changes)
5
+ # nop
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Tantot
2
+ module Performer
3
+ class Inline
4
+ def run(context, changes)
5
+ Tantot.collector.perform(context, changes)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ module Tantot
2
+ module Performer
3
+ class Sidekiq
4
+ class Worker
5
+ include ::Sidekiq::Worker
6
+
7
+ def perform(context, changes)
8
+ context, changes = Tantot.collector.unmarshal(context, changes)
9
+ Tantot.collector.perform(context, changes)
10
+ end
11
+ end
12
+
13
+ def run(context, changes)
14
+ context, changes = Tantot.collector.marshal(context, changes)
15
+ Tantot::Performer::Sidekiq::Worker.perform_async(context, changes)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ require 'tantot/performer/bypass'
2
+ require 'tantot/performer/inline'
3
+
4
+ begin
5
+ require 'sidekiq'
6
+ require 'tantot/performer/sidekiq'
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ module Tantot
12
+ module Performer
13
+ def self.resolve(name)
14
+ "Tantot::Performer::#{name.to_s.camelize}".safe_constantize or raise "Can't find performer class `#{name}`"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ module Tantot
2
+ class Railtie < Rails::Railtie
3
+ class RequestStrategy
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ Tantot.collector.run { @app.call(env) }
10
+ end
11
+ end
12
+
13
+ console do |app|
14
+ # Will sweep after every push (unfortunately)
15
+ Tantot.config.console_mode = true
16
+ end
17
+
18
+ initializer 'tantot.request_strategy' do |app|
19
+ Tantot.logger.debug { "[Tantot] Installing Rails middleware" }
20
+ app.config.middleware.insert_after(Rails::Rack::Logger, RequestStrategy)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ module Tantot
2
+ class Registry
3
+ include Singleton
4
+
5
+ attr_reader :watch_config
6
+
7
+ def initialize
8
+ @watch_config = {}
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module Tantot
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,16 @@
1
+ module Tantot
2
+ module Watcher
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :watcher_options_hash
7
+ end
8
+
9
+ class_methods do
10
+ def watcher_options(opts = {})
11
+ self.watcher_options_hash ||= Tantot::Config.instance.default_watcher_options
12
+ self.watcher_options_hash = self.watcher_options_hash.merge(opts)
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/tantot.rb ADDED
@@ -0,0 +1,62 @@
1
+ require 'tantot/version'
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+ require 'singleton'
6
+
7
+ require 'tantot/errors'
8
+ require 'tantot/config'
9
+ require 'tantot/registry'
10
+ require 'tantot/changes'
11
+ require 'tantot/watcher'
12
+ require 'tantot/performer'
13
+ require 'tantot/formatter'
14
+ require 'tantot/collector'
15
+ require 'tantot/observe'
16
+
17
+ require 'tantot/extensions/chewy'
18
+ require 'tantot/extensions/grape/middleware'
19
+
20
+ require 'tantot/railtie' if defined?(::Rails::Railtie)
21
+
22
+ ActiveSupport.on_load(:active_record) do
23
+ ActiveRecord::Base.send(:include, Tantot::Observe::ActiveRecordMethods)
24
+ ActiveRecord::Base.send(:include, Tantot::Extensions::Chewy)
25
+ end
26
+
27
+ module Tantot
28
+ class << self
29
+ attr_writer :logger
30
+
31
+ def derive_watcher(name)
32
+ watcher =
33
+ if name.is_a?(Class)
34
+ name
35
+ else
36
+ class_name = "#{name.camelize}Watcher"
37
+ watcher = class_name.safe_constantize
38
+ raise Tantot::UnderivableWatcher, "Can not find watcher named `#{class_name}`" unless watcher
39
+ watcher
40
+ end
41
+ raise Tantot::UnderivableWatcher, "Watcher class does not include Tantot::Watcher: #{watcher}" unless watcher.included_modules.include?(Tantot::Watcher)
42
+ watcher
43
+ end
44
+
45
+ def collector
46
+ Thread.current[:tantot_collector] ||= Tantot::Collector::Manager.new
47
+ end
48
+
49
+ def config
50
+ Tantot::Config.instance
51
+ end
52
+
53
+ def registry
54
+ Tantot::Registry.instance
55
+ end
56
+
57
+ def logger
58
+ @logger || Rails.logger
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,60 @@
1
+ require "spec_helper"
2
+
3
+ describe Tantot::Changes do
4
+
5
+ describe Tantot::Changes::ById do
6
+ let(:raw_changes) { {1 => {"id" => [nil, 1], "name" => [nil, "foo"]}, 2 => {"name" => ["foo", nil]}, 3 => {"id" => [3, nil], "name" => ["bar", "baz", nil]}} }
7
+ subject { described_class.new(raw_changes) }
8
+
9
+ it "should find ids" do
10
+ expect(subject.ids).to eq([1, 2, 3])
11
+ end
12
+
13
+ it "should find all values for an attribute, removing nil by default" do
14
+ expect(subject.for_attribute(:name)).to eq(["foo", "bar", "baz"])
15
+ end
16
+
17
+ it "should find all values for an attribute, including nil" do
18
+ expect(subject.for_attribute(:name, false)).to eq([nil, "foo", "bar", "baz"])
19
+ end
20
+
21
+ it "should find all changed attributes" do
22
+ expect(subject.attributes).to eq([:id, :name])
23
+ end
24
+
25
+ it "should correctly implement ==" do
26
+ expect(subject).to eq(described_class.new(raw_changes))
27
+ end
28
+
29
+ it "should implement Enumerable" do
30
+ expect(subject.collect {|id, changes| [id, changes]}).to eq(raw_changes.to_a)
31
+ end
32
+ end
33
+
34
+ describe Tantot::Changes::ByModel do
35
+ before do
36
+ stub_const('City', Class.new)
37
+ stub_const('Country', Class.new)
38
+ end
39
+ let(:city_changes) { {1 => {"id" => [nil, 1], "name" => [nil, "foo"]}, 2 => {"name" => ["foo", nil]}, 3 => {"id" => [3, nil], "name" => ["bar", "baz", nil]}} }
40
+ let(:country_changes) { {1 => {'id' => [nil, 1]}} }
41
+ let(:raw_changes) { {City => city_changes, Country => country_changes} }
42
+ subject { described_class.new(raw_changes) }
43
+
44
+ it "should find models" do
45
+ expect(subject.models).to eq([City, Country])
46
+ end
47
+
48
+ it "should implement []" do
49
+ expect(subject[City]).to eq(Tantot::Changes::ById.new(city_changes))
50
+ end
51
+
52
+ it "should implement Enumerable" do
53
+ expect(subject.collect {|model, changes| [model, changes]}).to eq([
54
+ [City, Tantot::Changes::ById.new(city_changes)],
55
+ [Country, Tantot::Changes::ById.new(country_changes)]
56
+ ])
57
+ end
58
+ end
59
+
60
+ end
@@ -0,0 +1,127 @@
1
+ require "spec_helper"
2
+
3
+ describe Tantot::Extensions::Chewy do
4
+
5
+ # Stub the Chewy namespace
6
+ before do
7
+ stub_const("Chewy", {})
8
+ end
9
+
10
+ [nil, :self, :class_method, :block].product([:some, :all]).each do |backreference_opt, attribute_opt|
11
+ it "should update indexes using backreference: #{backreference_opt.inspect}, attributes: #{attribute_opt}" do
12
+ chewy_type = double
13
+
14
+ watch_index_params = ['foo']
15
+ watch_index_params << :id if attribute_opt == :some
16
+
17
+ block_callback = proc do |changes|
18
+ self.yielded_changes = changes
19
+ [1, 2, 3]
20
+ end
21
+
22
+ case backreference_opt
23
+ when nil, :block
24
+ when :self
25
+ watch_index_params << {method: :self}
26
+ when :class_method
27
+ watch_index_params << {method: :class_get_ids}
28
+ end
29
+
30
+ stub_model(:city) do
31
+ class_attribute :yielded_changes
32
+
33
+ if backreference_opt == :block
34
+ watch_index(*watch_index_params, &block_callback)
35
+ else
36
+ watch_index(*watch_index_params)
37
+ end
38
+
39
+ def self.class_get_ids(changes)
40
+ self.yielded_changes = changes
41
+ [1, 2, 3]
42
+ end
43
+ end
44
+
45
+ city1 = city2 = nil
46
+
47
+ Tantot.collector.run do
48
+ city1 = City.create!
49
+ city2 = City.create!
50
+
51
+ # Stub the integration point between us and Chewy
52
+ expect(Chewy).to receive(:strategy).with(:atomic).and_yield
53
+ expect(Chewy).to receive(:derive_type).with('foo').and_return(chewy_type)
54
+
55
+ # Depending on backreference
56
+ case backreference_opt
57
+ when nil, :self
58
+ # Implicit and self reference will update with the created model id
59
+ expect(chewy_type).to receive(:update_index).with([city1.id, city2.id], {})
60
+ when :class_method, :block
61
+ # Validate that the returned ids are updated
62
+ expect(chewy_type).to receive(:update_index).with([1, 2, 3], {})
63
+ end
64
+ end
65
+
66
+ # Make sure the callbacks received the changes
67
+ if [:class_method, :block].include?(backreference_opt)
68
+ expect(City.yielded_changes).to eq(Tantot::Changes::ById.new({city1.id => {"id" => [nil, city1.id]}, city2.id => {"id" => [nil, city2.id]}}))
69
+ end
70
+
71
+ end
72
+ end
73
+
74
+ it "should allow registering an index watch on self (all attributes, destroy)" do
75
+ chewy_type = double
76
+
77
+ stub_model(:city) do
78
+ watch_index 'foo'
79
+ end
80
+
81
+ city = City.create!
82
+ Tantot.collector.sweep(performer: :bypass)
83
+
84
+ Tantot.collector.run do
85
+ city.destroy
86
+
87
+ expect(Chewy).to receive(:strategy).with(:atomic).and_yield
88
+ expect(Chewy).to receive(:derive_type).with('foo').and_return(chewy_type)
89
+ expect(chewy_type).to receive(:update_index).with([city.id], {})
90
+ end
91
+ end
92
+
93
+ it "should allow registering an index watch on self (all attributes, destroy, block)" do
94
+ chewy_type = double
95
+
96
+ stub_model(:city) do
97
+ watch_index 'foo' do |changes|
98
+ changes.ids
99
+ end
100
+ end
101
+
102
+ city = City.create!
103
+ Tantot.collector.sweep(performer: :bypass)
104
+
105
+ Tantot.collector.run do
106
+ city.destroy
107
+
108
+ expect(Chewy).to receive(:strategy).with(:atomic).and_yield
109
+ expect(Chewy).to receive(:derive_type).with('foo').and_return(chewy_type)
110
+ expect(chewy_type).to receive(:update_index).with([city.id], {})
111
+ end
112
+ end
113
+
114
+ it "should allow returning nothing in a callback" do
115
+ stub_model(:city) do
116
+ watch_index('foo') { 1 if false }
117
+ watch_index('bar') { [] }
118
+ watch_index('baz') { nil }
119
+ end
120
+
121
+ Tantot.collector.run do
122
+ City.create!
123
+
124
+ expect(Chewy).not_to receive(:derive_type)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,100 @@
1
+ require "spec_helper"
2
+
3
+ if defined?(::Sidekiq)
4
+ require 'sidekiq/testing'
5
+
6
+ describe Tantot::Performer::Sidekiq do
7
+ around do |example|
8
+ Tantot.config.performer = :sidekiq
9
+ example.run
10
+ Tantot.config.performer = :inline
11
+ end
12
+
13
+ describe Tantot::Collector::Watcher do
14
+
15
+ class SidekiqWatcher
16
+ include Tantot::Watcher
17
+
18
+ def perform(changes)
19
+ end
20
+ end
21
+
22
+ before do
23
+ Sidekiq::Worker.clear_all
24
+ stub_model(:city) do
25
+ watch SidekiqWatcher, :name
26
+ end
27
+ end
28
+
29
+ it "should call a sidekiq worker" do
30
+ Tantot.collector.run do
31
+ City.create name: 'foo'
32
+ end
33
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.size).to eq(1)
34
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.first["args"]).to eq([{"watcher" => "SidekiqWatcher", "collector_class" => "Tantot::Collector::Watcher"}, {"City" => {"1" => {"name" => [nil, 'foo']}}}])
35
+ end
36
+
37
+ it "should call the watcher" do
38
+ ::Sidekiq::Testing.inline! do
39
+ Tantot.collector.run do
40
+ city = City.create name: 'foo'
41
+ expect_any_instance_of(SidekiqWatcher).to receive(:perform).with(Tantot::Changes::ByModel.new({City => {city.id => {"name" => [nil, 'foo']}}}))
42
+ end
43
+ end
44
+ end
45
+
46
+ it "should skip sidekiq and process atomically when `sweep`ing, then resume using sidekiq" do
47
+ Sidekiq::Testing.fake! do
48
+ Tantot.collector.run do
49
+ # Create a model, then sweep. It should have called perform wihtout triggering a sidekiq worker
50
+ city = City.create name: 'foo'
51
+ expect_any_instance_of(SidekiqWatcher).to receive(:perform).with(Tantot::Changes::ByModel.new({City => {city.id => {"name" => [nil, 'foo']}}}))
52
+ Tantot.collector.sweep(performer: :inline, watcher: SidekiqWatcher)
53
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.size).to eq(0)
54
+
55
+ # Further modifications should trigger through sidekiq when exiting the strategy block
56
+ city.name = 'bar'
57
+ city.save
58
+ end
59
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.size).to eq(1)
60
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.first["args"]).to eq([{"watcher" => "SidekiqWatcher", "collector_class" => "Tantot::Collector::Watcher"}, {"City" => {"1" => {"name" => ['foo', 'bar']}}}])
61
+ end
62
+ end
63
+ end
64
+
65
+ describe Tantot::Collector::Block do
66
+ let(:value) { {changed: false} }
67
+ let(:changes) { {obj: nil} }
68
+
69
+ before do
70
+ Sidekiq::Worker.clear_all
71
+ v = value
72
+ c = changes
73
+ stub_model(:city) do
74
+ watch(:name) {|changes| v[:changed] = true; c[:obj] = changes}
75
+ end
76
+ end
77
+
78
+ it "should call a sidekiq worker" do
79
+ Tantot.collector.run do
80
+ City.create name: 'foo'
81
+ end
82
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.size).to eq(1)
83
+ block_id = Tantot.registry.watch_config.keys.last
84
+ expect(Tantot::Performer::Sidekiq::Worker.jobs.first["args"]).to eq([{"block_id" => block_id, "collector_class" => "Tantot::Collector::Block"}, {"1" => {"name" => [nil, 'foo']}}])
85
+ end
86
+
87
+ it "should call the watcher" do
88
+ ::Sidekiq::Testing.inline! do
89
+ city = nil
90
+ Tantot.collector.run do
91
+ city = City.create name: 'foo'
92
+ end
93
+ expect(value[:changed]).to be_truthy
94
+ expect(changes[:obj]).to eq(Tantot::Changes::ById.new({city.id => {"name" => [nil, 'foo']}}))
95
+ end
96
+ end
97
+ end
98
+
99
+ end
100
+ end
@@ -0,0 +1,47 @@
1
+ require 'bundler'
2
+
3
+ Bundler.require
4
+
5
+ require 'active_record'
6
+ require 'database_cleaner'
7
+
8
+ Tantot.logger = Logger.new(STDOUT)
9
+
10
+ def stub_class(name, superclass = nil, &block)
11
+ stub_const(name.to_s.camelize, Class.new(superclass || Object, &block))
12
+ end
13
+
14
+ def stub_model(name, superclass = nil, &block)
15
+ stub_class(name, superclass || ActiveRecord::Base, &block)
16
+ end
17
+
18
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
19
+
20
+ ActiveRecord::Schema.define do
21
+ create_table :countries do |t|
22
+ t.column :name, :string
23
+ t.column :country_code, :string
24
+ t.column :rating, :integer
25
+ end
26
+
27
+ create_table :cities do |t|
28
+ t.column :country_id, :integer
29
+ t.column :name, :string
30
+ t.column :rating, :integer
31
+ end
32
+ end
33
+
34
+ RSpec.configure do |config|
35
+ config.before(:suite) do
36
+ DatabaseCleaner.clean_with :transaction
37
+ DatabaseCleaner.strategy = :transaction
38
+ end
39
+
40
+ config.before do
41
+ DatabaseCleaner.start
42
+ end
43
+
44
+ config.after do
45
+ DatabaseCleaner.clean
46
+ end
47
+ end