tantot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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