lifesaver 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.rspec +1 -0
  2. data/.travis.yml +3 -1
  3. data/CHANGELOG.md +7 -0
  4. data/Gemfile +4 -0
  5. data/README.md +33 -13
  6. data/lib/lifesaver.rb +34 -11
  7. data/lib/lifesaver/config.rb +16 -0
  8. data/lib/lifesaver/index_worker.rb +10 -10
  9. data/lib/lifesaver/indexing/enqueuer.rb +37 -0
  10. data/lib/lifesaver/indexing/indexer.rb +40 -0
  11. data/lib/lifesaver/indexing/model_additions.rb +55 -0
  12. data/lib/lifesaver/notification/eager_loader.rb +48 -0
  13. data/lib/lifesaver/notification/enqueuer.rb +24 -0
  14. data/lib/lifesaver/notification/indexing_graph.rb +91 -0
  15. data/lib/lifesaver/notification/model_additions.rb +104 -0
  16. data/lib/lifesaver/notification/notifiable_associations.rb +49 -0
  17. data/lib/lifesaver/notification/traversal_queue.rb +48 -0
  18. data/lib/lifesaver/railtie.rb +4 -3
  19. data/lib/lifesaver/serialized_model.rb +3 -0
  20. data/lib/lifesaver/version.rb +1 -1
  21. data/lib/lifesaver/visitor_worker.rb +8 -4
  22. data/spec/integration/lifesaver_spec.rb +78 -0
  23. data/spec/spec_helper.rb +12 -10
  24. data/spec/support/active_record.rb +3 -3
  25. data/spec/support/test_models.rb +8 -6
  26. data/spec/support/tire_helper.rb +37 -0
  27. data/spec/unit/config_spec.rb +17 -0
  28. data/spec/unit/indexing/indexer_spec.rb +51 -0
  29. data/spec/unit/indexing/model_addtions_spec.rb +53 -0
  30. data/spec/unit/notification/eager_loader_spec.rb +64 -0
  31. data/spec/unit/notification/enqueuer_spec.rb +39 -0
  32. data/spec/unit/notification/indexing_graph_spec.rb +73 -0
  33. data/spec/unit/notification/model_additions_spec.rb +69 -0
  34. data/spec/unit/notification/notifiable_associations_spec.rb +75 -0
  35. data/spec/unit/notification/traversal_queue_spec.rb +67 -0
  36. metadata +40 -14
  37. data/lib/lifesaver/index_graph.rb +0 -53
  38. data/lib/lifesaver/marshal.rb +0 -43
  39. data/lib/lifesaver/model_additions.rb +0 -133
  40. data/spec/lifesaver/index_graph_spec.rb +0 -64
  41. data/spec/lifesaver/marshal_spec.rb +0 -80
  42. data/spec/lifesaver/model_additions_spec.rb +0 -91
  43. data/spec/lifesaver_spec.rb +0 -111
@@ -0,0 +1,104 @@
1
+ module Lifesaver
2
+ module Notification
3
+ module ModelAdditions
4
+ module ClassMethods
5
+ def notifies_for_indexing(*args)
6
+ self.notifiable_associations = NotifiableAssociations.new
7
+ options = args.last.is_a?(Hash) ? args.pop : {}
8
+ notifiable_associations.populate(args, options)
9
+ notification_callbacks
10
+ end
11
+
12
+ def load_with_notifiable_associations(ids)
13
+ includes(notifiable_associations.on_notify).where(id: ids)
14
+ end
15
+
16
+ private
17
+
18
+ def notification_callbacks
19
+ after_save do
20
+ send :update_associations, :update
21
+ end
22
+ before_destroy do
23
+ send :update_associations, :destroy
24
+ end
25
+ end
26
+ end
27
+
28
+ def self.included(base)
29
+ base.class_attribute :notifiable_associations
30
+ base.notifiable_associations = NotifiableAssociations.new
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ def associations_to_notify
35
+ models = []
36
+ self.class.notifiable_associations.on_notify.each do |association|
37
+ models |= models_for_association(association)
38
+ end
39
+ models
40
+ end
41
+
42
+ def needs_to_notify?
43
+ self.class.notifiable_associations.any_to_notify?
44
+ end
45
+
46
+ def models_for_association(assoc)
47
+ models = []
48
+ association = send(assoc)
49
+ unless association.nil?
50
+ if association.respond_to?(:each)
51
+ association.each do |m|
52
+ models << m
53
+ end
54
+ else
55
+ models << association
56
+ end
57
+ end
58
+ models
59
+ end
60
+
61
+ private
62
+
63
+ def update_associations(operation) models = []
64
+ to_skip = operation == :destroy ? dependent_associations : []
65
+ to_load = associations_to_load(:on_change, to_skip)
66
+
67
+ models = []
68
+ to_load.each { |key| models |= models_for_association(key) }
69
+ serialized_models = serialize_models(models)
70
+ enqueue_worker(serialized_models)
71
+ end
72
+
73
+ def associations_to_load(key, skip_associations)
74
+ associations = self.class.notifiable_associations.public_send(key)
75
+ skip_associations_map = {}
76
+ skip_associations.each { |assoc| skip_associations_map[assoc] = true }
77
+ associations.reject { |assoc| skip_associations_map[assoc] }
78
+ end
79
+
80
+ def dependent_associations
81
+ dependent_associations = []
82
+ self.class.reflect_on_all_associations.each do |association|
83
+ if association.options[:dependent].present?
84
+ dependent_associations << association.name.to_sym
85
+ end
86
+ end
87
+ dependent_associations
88
+ end
89
+
90
+ def serialize_models(models)
91
+ serialized_models = []
92
+ models.each do |m|
93
+ serialized_models << Lifesaver::SerializedModel.new(m.class.name, m.id)
94
+ end
95
+ serialized_models
96
+ end
97
+
98
+ def enqueue_worker(serialized_models)
99
+ Lifesaver::Notification::Enqueuer.new(serialized_models).enqueue
100
+ end
101
+
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,49 @@
1
+ module Lifesaver
2
+ module Notification
3
+ class NotifiableAssociations
4
+ class AssociationKeys < Struct.new(:on_change, :on_notify); end
5
+
6
+ def initialize
7
+ @association_keys = AssociationKeys.new([], [])
8
+ end
9
+
10
+ def populate(associations, options = nil)
11
+ options ||= {}
12
+ add_associations(:on_change, associations)
13
+ add_associations(:on_notify, associations)
14
+
15
+ if options[:only_on_change]
16
+ add_associations(:on_change, options[:only_on_change])
17
+ end
18
+
19
+ if options[:only_on_notify]
20
+ add_associations(:on_notify, options[:only_on_notify])
21
+ end
22
+ end
23
+
24
+ def on_notify
25
+ association_keys.on_notify
26
+ end
27
+
28
+ def on_change
29
+ association_keys.on_change
30
+ end
31
+
32
+ def any_to_notify?
33
+ !on_notify.empty?
34
+ end
35
+
36
+ private
37
+
38
+ attr_accessor :association_keys
39
+
40
+ def add_associations(key, associations)
41
+ if associations.is_a?(Array)
42
+ association_keys[key] |= associations
43
+ else
44
+ association_keys[key] << associations
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,48 @@
1
+ module Lifesaver
2
+ module Notification
3
+ class TraversalQueue
4
+ def initialize
5
+ @visited_models = {}
6
+ @queue = []
7
+ end
8
+
9
+ def size
10
+ queue.size
11
+ end
12
+
13
+ def push(model)
14
+ return if model_visited?(model)
15
+ visit_model(model)
16
+ queue << model
17
+ end
18
+
19
+ def <<(model)
20
+ push(model)
21
+ end
22
+
23
+ def pop
24
+ queue.shift
25
+ end
26
+
27
+ def empty?
28
+ queue.empty?
29
+ end
30
+
31
+ private
32
+
33
+ attr_accessor :queue, :visited_models
34
+
35
+ def visit_model(model)
36
+ visited_models[model_key(model)] = true
37
+ end
38
+
39
+ def model_visited?(model)
40
+ visited_models[model_key(model)] || false
41
+ end
42
+
43
+ def model_key(model)
44
+ "#{model.class.name}_#{model.id}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,9 +1,10 @@
1
1
  module Lifesaver
2
2
  class Railtie < Rails::Railtie
3
- initializer 'lifesaver.model_additions' do
3
+ initializer 'lifesaver.model' do
4
4
  ActiveSupport.on_load :active_record do
5
- include ModelAdditions
5
+ include Indexing::ModelAdditions
6
+ include Notification::ModelAdditions
6
7
  end
7
8
  end
8
9
  end
9
- end
10
+ end
@@ -0,0 +1,3 @@
1
+ module Lifesaver
2
+ class SerializedModel < Struct.new(:class_name, :id); end
3
+ end
@@ -1,3 +1,3 @@
1
1
  module Lifesaver
2
- VERSION = "0.0.1"
2
+ VERSION = '0.1.0'
3
3
  end
@@ -1,9 +1,13 @@
1
1
  class Lifesaver::VisitorWorker
2
2
  include Resque::Plugins::UniqueJob
3
- @queue = :lifesaver_notification
3
+
4
+ def self.queue; Lifesaver.config.notification_queue end
5
+
4
6
  def self.perform(models)
5
- Lifesaver::IndexGraph.generate(models).each do |m|
6
- Resque.enqueue(Lifesaver::IndexWorker, m.class.name.underscore.to_sym, m.id, :update) if m.has_index?
7
+ indexing_graph = Lifesaver::Notification::IndexingGraph.new
8
+ indexing_graph.initialize_models(models)
9
+ indexing_graph.generate.each do |m|
10
+ Lifesaver::Indexing::Enqueuer.new(model: m, operation: :update).enqueue
7
11
  end
8
12
  end
9
- end
13
+ end
@@ -0,0 +1,78 @@
1
+ require 'spec_helper'
2
+
3
+ describe Lifesaver do
4
+ before do
5
+ Lifesaver.suppress_indexing
6
+ end
7
+ let(:affiliate) { Affiliate.create(name: 'Prosper Forebearer') }
8
+ let(:author) do
9
+ Author.create(name: 'Theo Epstein', affiliate_id: affiliate.id)
10
+ end
11
+ let(:post) do
12
+ Post.create(
13
+ title: 'Lifesavers are my favorite candy',
14
+ content: 'Lorem ipsum',
15
+ tags: %w(candy stuff opinions)
16
+ )
17
+ end
18
+ let(:comment) do
19
+ Comment.create(
20
+ post: post,
21
+ text: 'We love this!'
22
+ )
23
+ end
24
+ before do
25
+ Authorship.create(post: post, author: author)
26
+ author.reload
27
+ post.reload
28
+
29
+ setup_indexes([author, post])
30
+
31
+ Lifesaver.unsuppress_indexing
32
+ end
33
+
34
+ it 'should traverse the provided graph' do
35
+ input = [{ 'class_name' => 'Author', 'id' => 1 }]
36
+ output = [author, post]
37
+ indexing_graph = Lifesaver::Notification::IndexingGraph.new
38
+ indexing_graph.initialize_models(input)
39
+
40
+ models = indexing_graph.generate
41
+
42
+ expect(models).to eql(output)
43
+ end
44
+
45
+ it 'should reindex on destroy' do
46
+ author.destroy
47
+ sleep(1.seconds)
48
+
49
+ expect(Author.search(query: 'Theo Epstein').count).to eql(0)
50
+ end
51
+
52
+ it 'should reindex on update' do
53
+ author.name = 'Harry Carry'
54
+ author.save!
55
+ sleep(1.seconds)
56
+
57
+ expect(Author.search(query: 'Harry Carry').count).to eql(1)
58
+ end
59
+
60
+ it 'should update distant related indexes' do
61
+ post.tags << 'werd'
62
+ post.save!
63
+ sleep(1.seconds)
64
+
65
+ expect(Author.search(query: 'werd').count).to eql(1)
66
+ end
67
+
68
+ it "should update related indexes if saved model doesn't have index" do
69
+ comment.text = 'We hate this!'
70
+ comment.save!
71
+ sleep(1.seconds)
72
+
73
+ result = Post.search(query: 'Lifesavers').to_a.first
74
+ comment_text = result.comments.first.text
75
+
76
+ expect(comment_text).to eql('We hate this!')
77
+ end
78
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,27 +1,29 @@
1
+ require 'pry'
1
2
  require 'coveralls'
2
- Coveralls.wear!
3
+ Coveralls.wear! if ENV['RUN_COVERALLS']
3
4
 
4
5
  require 'lifesaver'
5
6
 
6
7
  require 'support/active_record'
7
8
  require 'support/test_models'
8
- ActiveSupport.on_load :active_record do
9
- include Lifesaver::ModelAdditions
10
- end
9
+ require 'support/tire_helper'
11
10
 
12
11
  Resque.inline = true
13
- Tire::Model::Search.index_prefix "lifesaver_test"
12
+ Tire::Model::Search.index_prefix 'lifesaver_test'
14
13
 
14
+ Model = Struct.new(:id)
15
15
 
16
16
  RSpec.configure do |config|
17
- # config.expect_with :rspec do |c|
18
- # c.syntax = :expect
19
- # end
17
+ config.include TireHelper
18
+
19
+ config.expect_with :rspec do |c|
20
+ c.syntax = :expect
21
+ end
20
22
 
21
23
  config.around do |example|
22
24
  ActiveRecord::Base.transaction do
23
25
  example.run
24
- raise ActiveRecord::Rollback
26
+ fail ActiveRecord::Rollback
25
27
  end
26
28
  end
27
- end
29
+ end
@@ -1,5 +1,5 @@
1
1
  require 'active_record'
2
- ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
2
+ ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:'
3
3
 
4
4
  module ActiveModel::Validations
5
5
  # Extension to enhance `should have` on AR Model instances. Calls
@@ -16,7 +16,7 @@ module ActiveModel::Validations
16
16
  # model.errors_on(:attribute).should include("can't be blank")
17
17
  def errors_on(attribute)
18
18
  self.valid?
19
- [self.errors[attribute]].flatten.compact
19
+ [errors[attribute]].flatten.compact
20
20
  end
21
21
  alias :error_on :errors_on
22
22
  end
@@ -50,4 +50,4 @@ end
50
50
  ActiveRecord::Migration.create_table :affiliates do |t|
51
51
  t.string :name
52
52
  t.timestamps
53
- end
53
+ end
@@ -1,7 +1,8 @@
1
1
  require 'tire'
2
2
 
3
3
  ActiveSupport.on_load :active_record do
4
- include Lifesaver::ModelAdditions
4
+ include Lifesaver::Indexing::ModelAdditions
5
+ include Lifesaver::Notification::ModelAdditions
5
6
  end
6
7
 
7
8
 
@@ -11,6 +12,7 @@ class Author < ActiveRecord::Base
11
12
  belongs_to :affiliate
12
13
  enqueues_indexing
13
14
  include ::Tire::Model::Search
15
+
14
16
  notifies_for_indexing :authorships
15
17
  def post_tags
16
18
  tags = Set.new
@@ -22,8 +24,8 @@ class Author < ActiveRecord::Base
22
24
  mapping do
23
25
  indexes :id, type: 'integer', index: 'not_analyzed'
24
26
  indexes :name, type: 'multi_field', fields: {
25
- name: {type: 'string', analyzer: 'snowball'},
26
- untouched: {type: 'string', index: 'not_analyzed'}
27
+ name: { type: 'string', analyzer: 'snowball' },
28
+ untouched: { type: 'string', index: 'not_analyzed' }
27
29
  }
28
30
  indexes :post_tags, analyzer: 'keyword'
29
31
  end
@@ -64,8 +66,8 @@ class Post < ActiveRecord::Base
64
66
  mapping do
65
67
  indexes :id, type: 'integer', index: 'not_analyzed'
66
68
  indexes :title, type: 'multi_field', fields: {
67
- title: {type: 'string', analyzer: 'snowball'},
68
- untouched: {type: 'string', index: 'not_analyzed'}
69
+ title: { type: 'string', analyzer: 'snowball' },
70
+ untouched: { type: 'string', index: 'not_analyzed' }
69
71
  }
70
72
  end
71
73
 
@@ -87,4 +89,4 @@ end
87
89
  class Affiliate < ActiveRecord::Base
88
90
  has_many :authors
89
91
  notifies_for_indexing :authors
90
- end
92
+ end
@@ -0,0 +1,37 @@
1
+ module TireHelper
2
+ def setup_indexes(models)
3
+ @class_buckets = {}
4
+ bucketize_models(models)
5
+ refresh_indexes
6
+ end
7
+
8
+ private
9
+
10
+ attr_accessor :class_buckets
11
+
12
+ def bucketize_models(models)
13
+ models.each do |model|
14
+ add_model_to_bucket(model)
15
+ end
16
+ end
17
+
18
+ def add_model_to_bucket(model)
19
+ key = model.class.name
20
+ class_buckets[key] ||= []
21
+ class_buckets[key] << model
22
+ end
23
+
24
+ def refresh_indexes
25
+ class_buckets.each do |class_name, models|
26
+ refresh_index(class_name, models)
27
+ end
28
+ end
29
+
30
+ def refresh_index(class_name, models)
31
+ klass = class_name.constantize
32
+ klass.tire.index.delete
33
+ klass.tire.create_elasticsearch_index
34
+ models.each { |model| model.tire.update_index }
35
+ klass.tire.index.refresh
36
+ end
37
+ end