lifesaver 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.coveralls.yml ADDED
File without changes
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.0.0
5
+ services:
6
+ - redis-server
7
+ - elasticsearch
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## v0.0.1
2
+
3
+ * initial release
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in lifesaver.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'coveralls', require: false
8
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Paul Sorensen
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Lifesaver
2
+
3
+ [![Build Status](https://travis-ci.org/paulnsorensen/lifesaver.png?branch=master)](https://travis-ci.org/paulnsorensen/lifesaver)
4
+ [![Dependency Status](https://gemnasium.com/paulnsorensen/lifesaver.png)](https://gemnasium.com/paulnsorensen/lifesaver)
5
+ [![Coverage Status](https://coveralls.io/repos/paulnsorensen/lifesaver/badge.png)](https://coveralls.io/r/paulnsorensen/lifesaver)
6
+ [![Code Climate](https://codeclimate.com/github/paulnsorensen/lifesaver.png)](https://codeclimate.com/github/paulnsorensen/lifesaver)
7
+
8
+ Indexes your ActiveRecord models in [elasticsearch](https://github.com/elasticsearch/elasticsearch) asynchronously by making use of [tire](https://github.com/karmi/tire) and [resque](https://github.com/resque/resque) (hence the name: resque + tire = lifesaver). Using lifesaver, you can easily control when or if to reindex your model depending on your context. Lifesaver also provides the ability to traverse ActiveRecord associations to trigger the index updates of related models.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ gem 'lifesaver', git: "git://github.com/paulnsorensen/lifesaver.git"
15
+
16
+ And then execute:
17
+
18
+ $ bundle
19
+
20
+ Or install it yourself as:
21
+
22
+ $ gem install lifesaver
23
+
24
+ ## Usage
25
+
26
+ Replaces the tire callbacks in your models
27
+
28
+ ```ruby
29
+ class Article < ActiveRecord::Base
30
+ include Tire::Model::Search
31
+ # Replace the following include with Lifesaver
32
+ # include Tire::Model::Callbacks
33
+ enqueues_indexing
34
+ end
35
+ ```
36
+
37
+ #### Configurable Behavior
38
+ You can decided when or if the index gets updated at all based on your current situation. Lifesaver exposes two methods (`supress_indexing`, `unsuppress_indexing`) that set a model's indexing behavior until that model is saved.
39
+
40
+ ```ruby
41
+ class ArticlesController < ApplicationController
42
+ def suppressed_update
43
+ @article = Article.find(params[:id])
44
+ @article.attributes = params[:article]
45
+
46
+ # No reindexing will occur at all
47
+ @article.suppress_indexing
48
+
49
+ @article.save!
50
+
51
+ # Not neccessary but if saved
52
+ # after this following call,
53
+ # this article would reindex
54
+ @article.unsuppress_indexing
55
+ end
56
+ end
57
+ ```
58
+
59
+ #### ActiveRecord Association Traversal
60
+ Lifesaver can trigger other models to reindex if you have nested models in your indexes that you would like to update. Use the `notifies_for_indexing` method to indicate which related models should be marked for indexing. Any associations passed will be both updated when a model is changed (`save` or `destroy`) and when another model notifies it. Any associations passed in the options will only notify when the model is changed or notified when specified in the `only_on_change` or `only_on_notify` keys, respectively.
61
+
62
+ ```ruby
63
+ class Article < ActiveRecord::Base
64
+ belongs_to :author
65
+ belongs_to :category
66
+ has_many :watchers
67
+ has_one :moderator
68
+
69
+ notifies_for_indexing :author,
70
+ only_on_change: :category,
71
+ only_on_notify: [:watchers, :moderator]
72
+ end
73
+ ```
74
+
75
+ ## Integration with Resque
76
+ You will see two new queues: `lifesaver_indexing` and `lifesaver_notification`
77
+
78
+ ## Contributing
79
+
80
+ 1. Fork it
81
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
82
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
83
+ 4. Push to the branch (`git push origin my-new-feature`)
84
+ 5. Create new Pull Request
85
+
86
+ ## TODO
87
+ + specify which fields will trigger indexing changes
88
+ + configuration options
89
+ + bulk indexing
90
+ + resque-scheduler to provide `delay_indexing` and `enqueues_indexing after: 30.minutes, on: :save`
91
+ + unsuppress indexing after save
92
+ + sidekiq support
93
+ + prepare for new elasticsearch library
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task default: :spec
data/lib/lifesaver.rb ADDED
@@ -0,0 +1,24 @@
1
+ require 'resque-loner'
2
+ require "lifesaver/version"
3
+ require "lifesaver/marshal"
4
+ require "lifesaver/index_graph"
5
+ require "lifesaver/model_additions"
6
+ require "lifesaver/index_worker"
7
+ require "lifesaver/visitor_worker"
8
+ require "lifesaver/railtie" if defined? Rails
9
+
10
+ module Lifesaver
11
+ @@suppress_indexing = false
12
+
13
+ def self.suppress_indexing
14
+ @@suppress_indexing = true
15
+ end
16
+
17
+ def self.unsuppress_indexing
18
+ @@suppress_indexing = false
19
+ end
20
+
21
+ def self.indexing_suppressed?
22
+ @@suppress_indexing
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ module Lifesaver
2
+ class IndexGraph
3
+ def self.generate(marshalled_models)
4
+ models_to_index = []
5
+ visited_models = {}
6
+ graph = []
7
+ marshalled_models.each do |m|
8
+ mdl, opts = Lifesaver::Marshal.load(m)
9
+ if mdl
10
+ if opts[:status] == :notified
11
+ graph << mdl
12
+ elsif opts[:status] == :changed
13
+ visited_models[self.visited_model_key(mdl)] = true
14
+ graph |= self.notified_models(mdl, true)
15
+ end
16
+ end
17
+ end
18
+ graph.each {|m| visited_models[self.visited_model_key(m)] = true }
19
+ while !graph.empty?
20
+ mdl = graph.shift
21
+ models_to_index << mdl if mdl.has_index?
22
+ self.notified_models(mdl).each do |m|
23
+ unless visited_models[self.visited_model_key(m)]
24
+ visited_models[self.visited_model_key(m)] = true
25
+ graph << m
26
+ end
27
+ end
28
+ end
29
+ models_to_index
30
+ end
31
+
32
+ def self.visited_model_key(mdl)
33
+ if mdl.is_a?(Hash)
34
+ klass = mdl[:class].to_s.classify
35
+ "#{klass}_#{mdl[:id]}"
36
+ elsif mdl.try(:id)
37
+ "#{mdl.class.name}_#{mdl.id}"
38
+ end
39
+ end
40
+
41
+ def self.notified_models(mdl, on_change = false)
42
+ if Lifesaver::Marshal.is_serialized?(mdl)
43
+ mdl, opts = Lifesaver::Marshal.load(mdl)
44
+ end
45
+ models = []
46
+ key = on_change ? :on_change : :on_notify
47
+ mdl.class.notifiable_associations[key].each do |assoc|
48
+ models |= mdl.association_models(assoc)
49
+ end
50
+ models
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,13 @@
1
+ class Lifesaver::IndexWorker
2
+ include ::Resque::Plugins::UniqueJob
3
+ @queue = :lifesaver_indexing
4
+ def self.perform(class_name, id, action)
5
+ klass = class_name.to_s.classify.constantize
6
+ case action.to_sym
7
+ when :update
8
+ klass.find(id).update_index if klass.exists?(id)
9
+ when :destroy
10
+ klass.index.remove({type: klass.document_type, id: id})
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,43 @@
1
+ module Lifesaver
2
+ class Marshal
3
+ def self.dump(obj, opts={})
4
+ raise unless opts.is_a?(Hash)
5
+ opts[:class] = obj.class.name.underscore.to_sym
6
+ opts[:id] = obj.id
7
+ opts
8
+ end
9
+
10
+ def self.load(obj)
11
+ raise unless self.is_serialized?(obj)
12
+ obj = self.sanitize(obj)
13
+ klass = obj[:class].to_s.classify.constantize
14
+ if klass.exists?(obj[:id])
15
+ mdl = klass.find(obj[:id])
16
+ obj.delete(:id)
17
+ obj.delete(:class)
18
+ return mdl, obj
19
+ else
20
+ nil
21
+ end
22
+ end
23
+
24
+ def self.sanitize(obj)
25
+ raise unless obj.is_a?(Hash)
26
+ obj = obj.symbolize_keys
27
+ obj[:id] = obj[:id].to_i if obj[:id]
28
+ obj[:class] = obj[:class].to_sym if obj[:class]
29
+ obj[:status] = obj[:status].to_sym if obj[:status]
30
+ obj
31
+ end
32
+
33
+ def self.is_serialized?(obj)
34
+ if obj.is_a?(Hash)
35
+ obj = self.sanitize(obj)
36
+ if obj.key?(:class) && obj.key?(:id)
37
+ return true
38
+ end
39
+ end
40
+ false
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,133 @@
1
+ module Lifesaver
2
+ module ModelAdditions
3
+ module ClassMethods
4
+
5
+ def notifies_for_indexing(*args)
6
+ self.notifiable_associations = { on_change: [], on_notify: [] }
7
+ opts = args.pop if args.last.is_a?(Hash)
8
+ args.each do |a|
9
+ self.notifiable_associations[:on_change] << a
10
+ self.notifiable_associations[:on_notify] << a
11
+ end
12
+ %w(on_change on_notify).each do |k|
13
+ opt = opts[("only_" + k).to_sym] if opts
14
+ key = k.to_sym
15
+ if opt
16
+ if opt.is_a?(Array)
17
+ self.notifiable_associations[key] |= opt
18
+ else
19
+ self.notifiable_associations[key] << opt
20
+ end
21
+ end
22
+ end
23
+ notification_callbacks
24
+ end
25
+
26
+ def enqueues_indexing(options = {})
27
+ indexing_callbacks
28
+ end
29
+
30
+ private
31
+
32
+ def notification_callbacks(options={})
33
+ after_save do
34
+ send :update_associations, options.merge(operation: :update)
35
+ end
36
+ before_destroy do
37
+ send :update_associations, options.merge(operation: :destroy)
38
+ end
39
+ end
40
+
41
+ def indexing_callbacks(options={})
42
+ after_save do
43
+ send :enqueue_indexing, options.merge(operation: :update)
44
+ end
45
+ after_destroy do
46
+ send :enqueue_indexing, options.merge(operation: :destroy)
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.included(base)
52
+ base.class_attribute :notifiable_associations
53
+ base.notifiable_associations = { on_change: [], on_notify: [] }
54
+ base.extend(ClassMethods)
55
+ end
56
+
57
+ def association_models(assoc)
58
+ models = []
59
+ association = send(assoc.to_sym)
60
+ unless association.nil?
61
+ if association.respond_to?(:each)
62
+ association.each do |m|
63
+ models << m
64
+ end
65
+ else
66
+ models << association
67
+ end
68
+ end
69
+ models
70
+ end
71
+
72
+ def has_index?
73
+ self.respond_to?(:tire)
74
+ end
75
+
76
+ def suppress_indexing
77
+ @indexing_suppressed = true
78
+ end
79
+
80
+ def unsuppress_indexing
81
+ @indexing_suppressed = false
82
+ end
83
+
84
+ private
85
+
86
+ def enqueue_indexing(opts)
87
+ if has_index? && !suppress_indexing?
88
+ ::Resque.enqueue(
89
+ Lifesaver::IndexWorker,
90
+ self.class.name.underscore.to_sym,
91
+ self.id,
92
+ opts[:operation]
93
+ )
94
+ end
95
+ end
96
+
97
+ def dependent_association_map
98
+ dependent = {}
99
+ self.class.reflect_on_all_associations.each do |assoc|
100
+ dependent[assoc.name.to_sym] = true if assoc.options[:dependent].present?
101
+ end
102
+ dependent
103
+ end
104
+
105
+ def update_associations(opts)
106
+ models = []
107
+ if opts[:operation] == :destroy
108
+ dependent = dependent_association_map
109
+ assoc_models = []
110
+ self.class.notifiable_associations[:on_change].each do |assoc|
111
+ assoc_models |= association_models(assoc) unless dependent[assoc]
112
+ end
113
+ assoc_models.each do |m|
114
+ models << Lifesaver::Marshal.dump(m, {status: :notified})
115
+ end
116
+ elsif opts[:operation] == :update
117
+ models << Lifesaver::Marshal.dump(self, {status: :changed})
118
+ end
119
+
120
+ ::Resque.enqueue(Lifesaver::VisitorWorker, models) unless models.empty?
121
+ end
122
+
123
+ def validate_options(options)
124
+ # on: should only have active model callback verbs (create, update, destroy?)
125
+ # after: (next versions after you use resque scheduler) time to schedule
126
+ # only: specifies fields that trigger changes
127
+ end
128
+
129
+ def suppress_indexing?
130
+ Lifesaver.indexing_suppressed? || @indexing_suppressed || false
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,9 @@
1
+ module Lifesaver
2
+ class Railtie < Rails::Railtie
3
+ initializer 'lifesaver.model_additions' do
4
+ ActiveSupport.on_load :active_record do
5
+ include ModelAdditions
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module Lifesaver
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,9 @@
1
+ class Lifesaver::VisitorWorker
2
+ include Resque::Plugins::UniqueJob
3
+ @queue = :lifesaver_notification
4
+ 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
+ end
8
+ end
9
+ end
data/lifesaver.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'lifesaver/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "lifesaver"
8
+ spec.version = Lifesaver::VERSION
9
+ spec.authors = ["Paul Sorensen"]
10
+ spec.email = ["paulnsorensen@gmail.com"]
11
+ spec.description = %q{Indexes your ActiveRecord models in elasticsearch asynchronously by making use of tire and resque}
12
+ spec.summary = %q{Indexes your ActiveRecord models in elasticsearch asynchronously by making use of tire and resque}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.required_ruby_version = '>= 1.9'
22
+ spec.add_development_dependency "bundler", "~> 1.3"
23
+ spec.add_development_dependency "rake"
24
+ spec.add_development_dependency "rspec"
25
+ spec.add_development_dependency "activerecord"
26
+ spec.add_development_dependency "sqlite3"
27
+ spec.add_runtime_dependency 'activerecord', '>= 3'
28
+ spec.add_runtime_dependency 'tire', '~> 0.6.0'
29
+ spec.add_runtime_dependency 'resque', '~> 1.25.0'
30
+ spec.add_runtime_dependency 'resque-loner', '~> 1.2.1'
31
+ end
@@ -0,0 +1,64 @@
1
+ require 'spec_helper'
2
+
3
+ describe Lifesaver::IndexGraph do
4
+ Lifesaver.suppress_indexing
5
+
6
+ describe ".visited_model_key" do
7
+ it "should return a key when passed an ActiveRecord model" do
8
+ post = Post.create(title: "Some post")
9
+ expect(Lifesaver::IndexGraph.visited_model_key(post)).to eql("Post_1")
10
+ end
11
+
12
+ it "should return a key when passed a Hash" do
13
+ post = {class: "post", id: "1", status: "notified"}
14
+ expect(Lifesaver::IndexGraph.visited_model_key(post)).to eql("Post_1")
15
+ end
16
+ end
17
+
18
+ describe ".notified_models" do
19
+ after(:all) do
20
+ Post.destroy_all
21
+ Author.destroy_all
22
+ Authorship.destroy_all
23
+ end
24
+ context "when passed model has changed" do
25
+ before(:each) do
26
+ @post = Post.create(title: "Some post")
27
+ @author = Author.create(name: "Some guy")
28
+ Authorship.create(post: @post, author: @author)
29
+ end
30
+
31
+ it "should return notified models when passed an ActiveRecord model" do
32
+ models = Lifesaver::IndexGraph.notified_models(@post, true)
33
+ expect(models.size).to eql(1)
34
+ end
35
+
36
+ it "should return notified models when passed a Hash" do
37
+ post = {class: "post", id: "1", status: "changed"}
38
+ models = Lifesaver::IndexGraph.notified_models(post, true)
39
+ expect(models.size).to eql(1)
40
+ end
41
+ end
42
+
43
+ context "when passed model has not changed" do
44
+ before(:each) do
45
+ @post = Post.create(title: "Some post")
46
+ @author = Author.create(name: "Some guy")
47
+ Authorship.create(post: @post, author: @author)
48
+ end
49
+
50
+ it "should return notified models when passed an ActiveRecord model" do
51
+ models = Lifesaver::IndexGraph.notified_models(@author)
52
+ expect(models.size).to eql(1)
53
+ end
54
+
55
+ it "should return notified models when passed a Hash" do
56
+ author = {class: "author", id: "1", status: "notified"}
57
+ models = Lifesaver::IndexGraph.notified_models(author)
58
+ expect(models.size).to eql(1)
59
+ end
60
+ end
61
+ end
62
+
63
+ Lifesaver.unsuppress_indexing
64
+ end
@@ -0,0 +1,80 @@
1
+ require 'spec_helper'
2
+
3
+ describe Lifesaver::Marshal do
4
+ describe ".is_serialized?" do
5
+ it "returns true if Hash has :id and :class" do
6
+ obj = { class: :post, id: 1 }
7
+ expect(Lifesaver::Marshal.is_serialized?(obj)).to eql(true)
8
+ end
9
+
10
+ it "returns true if Hash has 'id' and 'class'" do
11
+ obj = { "class" => "post", "id" => "1" }
12
+ expect(Lifesaver::Marshal.is_serialized?(obj)).to eql(true)
13
+ end
14
+
15
+ it "returns false if Hash does not have id and class keys" do
16
+ obj = { some_key: 1, id: 4 }
17
+ expect(Lifesaver::Marshal.is_serialized?(obj)).to eql(false)
18
+ end
19
+
20
+ it "returns false if passed an non-Hash object" do
21
+ obj = Post.new
22
+ expect(Lifesaver::Marshal.is_serialized?(obj)).to eql(false)
23
+ end
24
+ end
25
+
26
+ describe ".sanitize" do
27
+ it "returns symbolized version of the Hash'" do
28
+ obj = { "class" => "post", "id" => "1", "status" => "updated" }
29
+ out = { class: :post, id: 1, status: :updated }
30
+ expect(Lifesaver::Marshal.sanitize(obj)).to eql(out)
31
+ end
32
+
33
+ it "rejects non-Hashes" do
34
+ obj = Post.new
35
+ expect { Lifesaver::Marshal.sanitize(obj) }.to raise_error
36
+ end
37
+ end
38
+
39
+ describe ".load" do
40
+ before(:all) do
41
+ Post.create(title: "Test Post")
42
+ end
43
+
44
+ it "loads a serialized object from ActiveRecord" do
45
+ obj = { class: :post, id: 1 }
46
+ expect(Lifesaver::Marshal.load(obj)).to eql([Post.find(1), {}])
47
+ end
48
+
49
+ it "returns nil if model not found" do
50
+ obj = { class: :post, id: 12 }
51
+ expect(Lifesaver::Marshal.load(obj)).to eql(nil)
52
+ end
53
+
54
+ it "returns options if they were passed" do
55
+ obj = { class: :post, id: 1, status: :notified }
56
+ m = Lifesaver::Marshal.load(obj)
57
+ expect(m).to eql([Post.find(1), {status: :notified}])
58
+ end
59
+
60
+
61
+ it "rejects bad input" do
62
+ obj = Post.new
63
+ expect { Lifesaver::Marshal.load(obj) }.to raise_error
64
+ end
65
+ end
66
+
67
+ describe ".dump" do
68
+ it "decomposes an object to a Hash" do
69
+ obj = Post.create(title: "Test Post")
70
+ out = { class: :post, id: 2 }
71
+ expect(Lifesaver::Marshal.dump(obj)).to eql(out)
72
+ end
73
+
74
+ it "adds additional key, value pairs if passed" do
75
+ obj = Post.create(title: "Test Post")
76
+ out = { status: :updated, class: :post, id: 2 }
77
+ expect(Lifesaver::Marshal.dump(obj, {status: :updated})).to eql(out)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,91 @@
1
+ require 'spec_helper'
2
+
3
+ describe Lifesaver::ModelAdditions do
4
+ Lifesaver.suppress_indexing
5
+
6
+ describe ".notifies_for_indexing" do
7
+ after(:each) do
8
+ Post.send(:notifies_for_indexing, only_on_change: :authorships)
9
+ end
10
+ it "should return a Hash of models for notification on save and notify" do
11
+ Post.send(:notifies_for_indexing, :comments,
12
+ only_on_change: :authorships,
13
+ only_on_notify: :authors
14
+ )
15
+ exp_hash = { on_change: [], on_notify: [] }
16
+ exp_hash[:on_change] << :comments
17
+ exp_hash[:on_change] << :authorships
18
+ exp_hash[:on_notify] << :comments
19
+ exp_hash[:on_notify] << :authors
20
+ expect(Post.notifiable_associations).to eql(exp_hash)
21
+ end
22
+ end
23
+
24
+ describe "#association_models" do
25
+ before(:each) do
26
+ @post = Post.create(title: "Some post")
27
+ affiliate = Affiliate.create(name: "Some place")
28
+ @author = Author.create(name: "Some guy", affiliate_id: affiliate.id)
29
+ Authorship.create(post: @post, author: @author)
30
+ end
31
+ after(:all) do
32
+ Post.destroy_all
33
+ Author.destroy_all
34
+ Authorship.destroy_all
35
+ Affiliate.destroy_all
36
+ end
37
+
38
+ it "should return an array of models for multiple association" do
39
+ association = @post.association_models(:authorships)
40
+ expect(association[0]).to be_a_kind_of(Authorship)
41
+ end
42
+
43
+ it "should return an array of one model for a singular association" do
44
+ association = @author.association_models(:affiliate)
45
+ expect(association[0]).to be_a_kind_of(Affiliate)
46
+ end
47
+ end
48
+
49
+ Lifesaver.unsuppress_indexing
50
+
51
+ describe "#indexing_suppressed?" do
52
+ let(:post) { Post.new(title: "Test Post") }
53
+
54
+ it "should be false by default" do
55
+ expect(post.send(:suppress_indexing?)).to eql(false)
56
+ end
57
+
58
+ it "should be true if overridden locally" do
59
+ Lifesaver.suppress_indexing
60
+ expect(post.send(:suppress_indexing?)).to eql(true)
61
+ end
62
+
63
+ it "should be false if override is cancelled" do
64
+ Lifesaver.unsuppress_indexing
65
+ expect(post.send(:suppress_indexing?)).to eql(false)
66
+ end
67
+
68
+ it "should be true if set individually" do
69
+ post.suppress_indexing
70
+ expect(post.send(:suppress_indexing?)).to eql(true)
71
+ end
72
+
73
+ it "should be false if unset individually" do
74
+ post.unsuppress_indexing
75
+ expect(post.send(:suppress_indexing?)).to eql(false)
76
+ end
77
+ end
78
+
79
+ describe "#dependent_association_map" do
80
+ it "returns an empty Hash when there are no dependent associations" do
81
+ expect(Comment.new.send(:dependent_association_map)).to eql({})
82
+ end
83
+
84
+ it "returns a Hash with the keys of dependent_association" do
85
+ dependent_map = Author.new.send(:dependent_association_map)
86
+ expect(dependent_map).to eql({authorships: true})
87
+ end
88
+
89
+ end
90
+
91
+ end
@@ -0,0 +1,111 @@
1
+ require 'spec_helper'
2
+
3
+ describe Lifesaver do
4
+ before(:all) do
5
+ Lifesaver.suppress_indexing
6
+ Post.destroy_all
7
+ Author.destroy_all
8
+ Authorship.destroy_all
9
+ Affiliate.destroy_all
10
+ Comment.destroy_all
11
+ Lifesaver.unsuppress_indexing
12
+ end
13
+ before(:each) do
14
+ [Author, Post].each do |klass|
15
+ klass.tire.index.delete
16
+ klass.tire.create_elasticsearch_index
17
+ end
18
+
19
+ Lifesaver.suppress_indexing
20
+
21
+ @posts = []
22
+ @posts << Post.create(
23
+ title: "Lifesavers are my favorite candy",
24
+ content: "Lorem ipsum",
25
+ tags: %w(candy stuff opinions)
26
+ )
27
+ @posts << Post.create(
28
+ title: "Birds are the best animal",
29
+ content: "Lorem ipsum",
30
+ tags: %w(animals stuff facts)
31
+ )
32
+ @posts << Post.create(
33
+ title: "Chicago Cubs have a winning season",
34
+ content: "Lorem ipsum",
35
+ tags: %w(sports stuff jokes)
36
+ )
37
+ @comments = []
38
+ @comments << Comment.create(
39
+ post: @posts.last,
40
+ text: "We love this!"
41
+ )
42
+ @comments << Comment.create(
43
+ post: @posts.last,
44
+ text: "We lied. Didn't realize it was a joke."
45
+ )
46
+ @authors = []
47
+ @authors << Author.create(name: "Paul Sorensen")
48
+ @authors << Author.create(name: "Paul Sorensen's Ghost Writer")
49
+ @authors << Author.create(name: "Theo Epstein")
50
+ @affiliates = []
51
+ @affiliates << Affiliate.create(name: "Prosper Forebearer")
52
+ @affiliates << Affiliate.create(name: "Chicago Cubs")
53
+ @authors[0].affiliate_id = @affiliates.first.id
54
+ @authors[1].affiliate_id = @affiliates.first.id
55
+ @authors[2].affiliate_id = @affiliates.last.id
56
+ @authors.each { |a| a.save! }
57
+ Authorship.create(post: @posts[0], author: @authors[0])
58
+ Authorship.create(post: @posts[1], author: @authors[0])
59
+ Authorship.create(post: @posts[1], author: @authors[1])
60
+ Authorship.create(post: @posts[2], author: @authors[2])
61
+
62
+ Lifesaver.unsuppress_indexing
63
+
64
+ [Author, Post].each do |klass|
65
+ klass.all.each { |k| k.tire.update_index }
66
+ klass.tire.index.refresh
67
+ end
68
+ end
69
+
70
+ after(:each) do
71
+ Post.destroy_all
72
+ Author.destroy_all
73
+ Authorship.destroy_all
74
+ Affiliate.destroy_all
75
+ Comment.destroy_all
76
+ end
77
+
78
+ it "should traverse the provided graph" do
79
+ models = Lifesaver::IndexGraph.generate([{"class"=>"author", "id"=>1, "status"=>"changed"}])
80
+ expect(models.size).to eql(2)
81
+ end
82
+
83
+ it "should reindex on destroy" do
84
+ @authors[2].destroy
85
+ sleep(1.seconds)
86
+ expect(Author.search(query: "Theo Epstein").to_a.size).to eql(0)
87
+ end
88
+
89
+ it "should reindex on update" do
90
+ @authors[2].name = "Harry Carry"
91
+ @authors[2].save!
92
+ sleep(1.seconds) # need to wait for elasticsearch to update
93
+ expect(Author.search(query: "Harry Carry").to_a.size).to eql(1)
94
+ end
95
+
96
+ it "should update distant related indexes" do
97
+ @posts[0].tags << 'werd'
98
+ @posts[0].save!
99
+ sleep(1.seconds)
100
+ expect(Author.search(query: "werd").to_a.size).to eql(1)
101
+ end
102
+
103
+ it "should update related indexes if saved model doesn't have index" do
104
+ @comments[0].text = "We hate this!"
105
+ @comments[0].save!
106
+ sleep(1.seconds)
107
+ result = Post.search(query: "Chicago").to_a.first
108
+ comment_text = result.comments.first.text
109
+ expect(comment_text).to eql("We hate this!")
110
+ end
111
+ end
@@ -0,0 +1,27 @@
1
+ require 'coveralls'
2
+ Coveralls.wear!
3
+
4
+ require 'lifesaver'
5
+
6
+ require 'support/active_record'
7
+ require 'support/test_models'
8
+ ActiveSupport.on_load :active_record do
9
+ include Lifesaver::ModelAdditions
10
+ end
11
+
12
+ Resque.inline = true
13
+ Tire::Model::Search.index_prefix "lifesaver_test"
14
+
15
+
16
+ RSpec.configure do |config|
17
+ # config.expect_with :rspec do |c|
18
+ # c.syntax = :expect
19
+ # end
20
+
21
+ config.around do |example|
22
+ ActiveRecord::Base.transaction do
23
+ example.run
24
+ raise ActiveRecord::Rollback
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ require 'active_record'
2
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
3
+
4
+ module ActiveModel::Validations
5
+ # Extension to enhance `should have` on AR Model instances. Calls
6
+ # model.valid? in order to prepare the object's errors object.
7
+ #
8
+ # You can also use this to specify the content of the error messages.
9
+ #
10
+ # @example
11
+ #
12
+ # model.should have(:no).errors_on(:attribute)
13
+ # model.should have(1).error_on(:attribute)
14
+ # model.should have(n).errors_on(:attribute)
15
+ #
16
+ # model.errors_on(:attribute).should include("can't be blank")
17
+ def errors_on(attribute)
18
+ self.valid?
19
+ [self.errors[attribute]].flatten.compact
20
+ end
21
+ alias :error_on :errors_on
22
+ end
23
+
24
+ # test models we'll use in each spec
25
+ ActiveRecord::Migration.create_table :comments do |t|
26
+ t.string :text
27
+ t.integer :post_id
28
+ t.timestamps
29
+ end
30
+
31
+ ActiveRecord::Migration.create_table :posts do |t|
32
+ t.string :title
33
+ t.text :content
34
+ t.text :tags
35
+ t.timestamps
36
+ end
37
+
38
+ ActiveRecord::Migration.create_table :authors do |t|
39
+ t.string :name
40
+ t.integer :affiliate_id
41
+ t.timestamps
42
+ end
43
+
44
+ ActiveRecord::Migration.create_table :authorships do |t|
45
+ t.integer :post_id
46
+ t.integer :author_id
47
+ t.timestamps
48
+ end
49
+
50
+ ActiveRecord::Migration.create_table :affiliates do |t|
51
+ t.string :name
52
+ t.timestamps
53
+ end
@@ -0,0 +1,90 @@
1
+ require 'tire'
2
+
3
+ ActiveSupport.on_load :active_record do
4
+ include Lifesaver::ModelAdditions
5
+ end
6
+
7
+
8
+ class Author < ActiveRecord::Base
9
+ has_many :authorships, dependent: :destroy
10
+ has_many :posts, through: :authorships
11
+ belongs_to :affiliate
12
+ enqueues_indexing
13
+ include ::Tire::Model::Search
14
+ notifies_for_indexing :authorships
15
+ def post_tags
16
+ tags = Set.new
17
+ posts.select(:tags).each do |p|
18
+ tags |= Set.new(p.tags)
19
+ end
20
+ tags.to_a
21
+ end
22
+ mapping do
23
+ indexes :id, type: 'integer', index: 'not_analyzed'
24
+ indexes :name, type: 'multi_field', fields: {
25
+ name: {type: 'string', analyzer: 'snowball'},
26
+ untouched: {type: 'string', index: 'not_analyzed'}
27
+ }
28
+ indexes :post_tags, analyzer: 'keyword'
29
+ end
30
+ def self.search(params)
31
+ tire.search do
32
+ size 100
33
+ query { string params[:query] } if params[:query].present?
34
+ sort do
35
+ by 'name.untouched', :asc
36
+ end
37
+ filter :term, affiliate_id: params[:afilliate_id] if params[:affiliate_id].present?
38
+ end
39
+ end
40
+ def to_indexed_json
41
+ to_json(include: :affiliate, methods: :post_tags)
42
+ end
43
+ end
44
+
45
+ class Authorship < ActiveRecord::Base
46
+ belongs_to :author
47
+ belongs_to :post
48
+ notifies_for_indexing only_on_notify: [:author, :post]
49
+ end
50
+
51
+ class Comment < ActiveRecord::Base
52
+ belongs_to :post
53
+ notifies_for_indexing :post
54
+ end
55
+
56
+ class Post < ActiveRecord::Base
57
+ has_many :comments
58
+ has_many :authorships, dependent: :destroy
59
+ has_many :authors, through: :authorships
60
+ serialize :tags, Array
61
+ enqueues_indexing
62
+ notifies_for_indexing only_on_change: :authorships
63
+ include ::Tire::Model::Search
64
+ mapping do
65
+ indexes :id, type: 'integer', index: 'not_analyzed'
66
+ indexes :title, type: 'multi_field', fields: {
67
+ title: {type: 'string', analyzer: 'snowball'},
68
+ untouched: {type: 'string', index: 'not_analyzed'}
69
+ }
70
+ end
71
+
72
+ def self.search(params)
73
+ tire.search do
74
+ size 100
75
+ query { string params[:query] } if params[:query].present?
76
+ sort do
77
+ by 'title.untouched', :asc
78
+ end
79
+ end
80
+ end
81
+
82
+ def to_indexed_json
83
+ to_json(include: :comments, authors: { include: :affiliate })
84
+ end
85
+ end
86
+
87
+ class Affiliate < ActiveRecord::Base
88
+ has_many :authors
89
+ notifies_for_indexing :authors
90
+ end
metadata ADDED
@@ -0,0 +1,223 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lifesaver
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Paul Sorensen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-10-09 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '1.3'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '1.3'
30
+ - !ruby/object:Gem::Dependency
31
+ name: rake
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: activerecord
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: sqlite3
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: activerecord
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '3'
102
+ type: :runtime
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '3'
110
+ - !ruby/object:Gem::Dependency
111
+ name: tire
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ~>
116
+ - !ruby/object:Gem::Version
117
+ version: 0.6.0
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ~>
124
+ - !ruby/object:Gem::Version
125
+ version: 0.6.0
126
+ - !ruby/object:Gem::Dependency
127
+ name: resque
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ~>
132
+ - !ruby/object:Gem::Version
133
+ version: 1.25.0
134
+ type: :runtime
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ~>
140
+ - !ruby/object:Gem::Version
141
+ version: 1.25.0
142
+ - !ruby/object:Gem::Dependency
143
+ name: resque-loner
144
+ requirement: !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ~>
148
+ - !ruby/object:Gem::Version
149
+ version: 1.2.1
150
+ type: :runtime
151
+ prerelease: false
152
+ version_requirements: !ruby/object:Gem::Requirement
153
+ none: false
154
+ requirements:
155
+ - - ~>
156
+ - !ruby/object:Gem::Version
157
+ version: 1.2.1
158
+ description: Indexes your ActiveRecord models in elasticsearch asynchronously by making
159
+ use of tire and resque
160
+ email:
161
+ - paulnsorensen@gmail.com
162
+ executables: []
163
+ extensions: []
164
+ extra_rdoc_files: []
165
+ files:
166
+ - .coveralls.yml
167
+ - .gitignore
168
+ - .travis.yml
169
+ - CHANGELOG.md
170
+ - Gemfile
171
+ - LICENSE.txt
172
+ - README.md
173
+ - Rakefile
174
+ - lib/lifesaver.rb
175
+ - lib/lifesaver/index_graph.rb
176
+ - lib/lifesaver/index_worker.rb
177
+ - lib/lifesaver/marshal.rb
178
+ - lib/lifesaver/model_additions.rb
179
+ - lib/lifesaver/railtie.rb
180
+ - lib/lifesaver/version.rb
181
+ - lib/lifesaver/visitor_worker.rb
182
+ - lifesaver.gemspec
183
+ - spec/lifesaver/index_graph_spec.rb
184
+ - spec/lifesaver/marshal_spec.rb
185
+ - spec/lifesaver/model_additions_spec.rb
186
+ - spec/lifesaver_spec.rb
187
+ - spec/spec_helper.rb
188
+ - spec/support/active_record.rb
189
+ - spec/support/test_models.rb
190
+ homepage: ''
191
+ licenses:
192
+ - MIT
193
+ post_install_message:
194
+ rdoc_options: []
195
+ require_paths:
196
+ - lib
197
+ required_ruby_version: !ruby/object:Gem::Requirement
198
+ none: false
199
+ requirements:
200
+ - - ! '>='
201
+ - !ruby/object:Gem::Version
202
+ version: '1.9'
203
+ required_rubygems_version: !ruby/object:Gem::Requirement
204
+ none: false
205
+ requirements:
206
+ - - ! '>='
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ requirements: []
210
+ rubyforge_project:
211
+ rubygems_version: 1.8.23
212
+ signing_key:
213
+ specification_version: 3
214
+ summary: Indexes your ActiveRecord models in elasticsearch asynchronously by making
215
+ use of tire and resque
216
+ test_files:
217
+ - spec/lifesaver/index_graph_spec.rb
218
+ - spec/lifesaver/marshal_spec.rb
219
+ - spec/lifesaver/model_additions_spec.rb
220
+ - spec/lifesaver_spec.rb
221
+ - spec/spec_helper.rb
222
+ - spec/support/active_record.rb
223
+ - spec/support/test_models.rb