searchkick-hooopo 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.travis.yml +35 -0
  4. data/CHANGELOG.md +491 -0
  5. data/Gemfile +12 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +1908 -0
  8. data/Rakefile +20 -0
  9. data/benchmark/Gemfile +23 -0
  10. data/benchmark/benchmark.rb +97 -0
  11. data/lib/searchkick/bulk_reindex_job.rb +17 -0
  12. data/lib/searchkick/index.rb +500 -0
  13. data/lib/searchkick/index_options.rb +333 -0
  14. data/lib/searchkick/indexer.rb +28 -0
  15. data/lib/searchkick/logging.rb +242 -0
  16. data/lib/searchkick/middleware.rb +12 -0
  17. data/lib/searchkick/model.rb +156 -0
  18. data/lib/searchkick/process_batch_job.rb +23 -0
  19. data/lib/searchkick/process_queue_job.rb +23 -0
  20. data/lib/searchkick/query.rb +901 -0
  21. data/lib/searchkick/reindex_queue.rb +38 -0
  22. data/lib/searchkick/reindex_v2_job.rb +39 -0
  23. data/lib/searchkick/results.rb +216 -0
  24. data/lib/searchkick/tasks.rb +33 -0
  25. data/lib/searchkick/version.rb +3 -0
  26. data/lib/searchkick.rb +215 -0
  27. data/searchkick.gemspec +28 -0
  28. data/test/aggs_test.rb +197 -0
  29. data/test/autocomplete_test.rb +75 -0
  30. data/test/boost_test.rb +175 -0
  31. data/test/callbacks_test.rb +59 -0
  32. data/test/ci/before_install.sh +17 -0
  33. data/test/errors_test.rb +19 -0
  34. data/test/gemfiles/activerecord31.gemfile +7 -0
  35. data/test/gemfiles/activerecord32.gemfile +7 -0
  36. data/test/gemfiles/activerecord40.gemfile +8 -0
  37. data/test/gemfiles/activerecord41.gemfile +8 -0
  38. data/test/gemfiles/activerecord42.gemfile +7 -0
  39. data/test/gemfiles/activerecord50.gemfile +7 -0
  40. data/test/gemfiles/apartment.gemfile +8 -0
  41. data/test/gemfiles/cequel.gemfile +8 -0
  42. data/test/gemfiles/mongoid2.gemfile +7 -0
  43. data/test/gemfiles/mongoid3.gemfile +6 -0
  44. data/test/gemfiles/mongoid4.gemfile +7 -0
  45. data/test/gemfiles/mongoid5.gemfile +7 -0
  46. data/test/gemfiles/mongoid6.gemfile +8 -0
  47. data/test/gemfiles/nobrainer.gemfile +8 -0
  48. data/test/gemfiles/parallel_tests.gemfile +8 -0
  49. data/test/geo_shape_test.rb +172 -0
  50. data/test/highlight_test.rb +78 -0
  51. data/test/index_test.rb +153 -0
  52. data/test/inheritance_test.rb +83 -0
  53. data/test/marshal_test.rb +8 -0
  54. data/test/match_test.rb +276 -0
  55. data/test/misspellings_test.rb +56 -0
  56. data/test/model_test.rb +42 -0
  57. data/test/multi_search_test.rb +22 -0
  58. data/test/multi_tenancy_test.rb +22 -0
  59. data/test/order_test.rb +46 -0
  60. data/test/pagination_test.rb +53 -0
  61. data/test/partial_reindex_test.rb +58 -0
  62. data/test/query_test.rb +35 -0
  63. data/test/records_test.rb +10 -0
  64. data/test/reindex_test.rb +52 -0
  65. data/test/reindex_v2_job_test.rb +32 -0
  66. data/test/routing_test.rb +23 -0
  67. data/test/should_index_test.rb +32 -0
  68. data/test/similar_test.rb +28 -0
  69. data/test/sql_test.rb +198 -0
  70. data/test/suggest_test.rb +85 -0
  71. data/test/synonyms_test.rb +67 -0
  72. data/test/test_helper.rb +527 -0
  73. data/test/where_test.rb +223 -0
  74. metadata +250 -0
@@ -0,0 +1,38 @@
1
+ module Searchkick
2
+ class ReindexQueue
3
+ attr_reader :name
4
+
5
+ def initialize(name)
6
+ @name = name
7
+
8
+ raise Searchkick::Error, "Searchkick.redis not set" unless Searchkick.redis
9
+ end
10
+
11
+ def push(record_id)
12
+ Searchkick.with_redis { |r| r.lpush(redis_key, record_id) }
13
+ end
14
+
15
+ # TODO use reliable queuing
16
+ def reserve(limit: 1000)
17
+ record_ids = Set.new
18
+ while record_ids.size < limit && record_id = Searchkick.with_redis { |r| r.rpop(redis_key) }
19
+ record_ids << record_id
20
+ end
21
+ record_ids.to_a
22
+ end
23
+
24
+ def clear
25
+ Searchkick.with_redis { |r| r.del(redis_key) }
26
+ end
27
+
28
+ def length
29
+ Searchkick.with_redis { |r| r.llen(redis_key) }
30
+ end
31
+
32
+ private
33
+
34
+ def redis_key
35
+ "searchkick:reindex_queue:#{name}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ module Searchkick
2
+ class ReindexV2Job < ActiveJob::Base
3
+ RECORD_NOT_FOUND_CLASSES = [
4
+ "ActiveRecord::RecordNotFound",
5
+ "Mongoid::Errors::DocumentNotFound",
6
+ "NoBrainer::Error::DocumentNotFound",
7
+ "Cequel::Record::RecordNotFound"
8
+ ]
9
+
10
+ queue_as { Searchkick.queue_name }
11
+
12
+ def perform(klass, id)
13
+ model = klass.constantize
14
+ record =
15
+ begin
16
+ model.find(id)
17
+ rescue => e
18
+ # check by name rather than rescue directly so we don't need
19
+ # to determine which classes are defined
20
+ raise e unless RECORD_NOT_FOUND_CLASSES.include?(e.class.name)
21
+ nil
22
+ end
23
+
24
+ index = model.searchkick_index
25
+ if !record || !record.should_index?
26
+ # hacky
27
+ record ||= model.new
28
+ record.id = id
29
+ begin
30
+ index.remove record
31
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
32
+ # do nothing
33
+ end
34
+ else
35
+ index.store record
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,216 @@
1
+ require "forwardable"
2
+
3
+ module Searchkick
4
+ class Results
5
+ include Enumerable
6
+ extend Forwardable
7
+
8
+ attr_reader :klass, :response, :options
9
+
10
+ def_delegators :results, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary
11
+
12
+ def initialize(klass, response, options = {})
13
+ @klass = klass
14
+ @response = response
15
+ @options = options
16
+ end
17
+
18
+ # experimental: may not make next release
19
+ def records
20
+ @records ||= results_query(klass, hits)
21
+ end
22
+
23
+ def results
24
+ @results ||= begin
25
+ if options[:load]
26
+ # results can have different types
27
+ results = {}
28
+
29
+ hits.group_by { |hit, _| hit["_type"] }.each do |type, grouped_hits|
30
+ results[type] = results_query(type.camelize.constantize, grouped_hits).to_a.index_by { |r| r.id.to_s }
31
+ end
32
+
33
+ # sort
34
+ hits.map do |hit|
35
+ result = results[hit["_type"]][hit["_id"].to_s]
36
+ if result && !(options[:load].is_a?(Hash) && options[:load][:dumpable])
37
+ unless result.respond_to?(:search_hit)
38
+ result.define_singleton_method(:search_hit) do
39
+ hit
40
+ end
41
+ end
42
+
43
+ if hit["highlight"] && !result.respond_to?(:search_highlights)
44
+ highlights = Hash[hit["highlight"].map { |k, v| [(options[:json] ? k : k.sub(/\.#{@options[:match_suffix]}\z/, "")).to_sym, v.first] }]
45
+ result.define_singleton_method(:search_highlights) do
46
+ highlights
47
+ end
48
+ end
49
+ end
50
+ result
51
+ end.compact
52
+ else
53
+ hits.map do |hit|
54
+ result =
55
+ if hit["_source"]
56
+ hit.except("_source").merge(hit["_source"])
57
+ elsif hit["fields"]
58
+ hit.except("fields").merge(hit["fields"])
59
+ else
60
+ hit
61
+ end
62
+
63
+ if hit["highlight"]
64
+ highlight = Hash[hit["highlight"].map { |k, v| [base_field(k), v.first] }]
65
+ options[:highlighted_fields].map { |k| base_field(k) }.each do |k|
66
+ result["highlighted_#{k}"] ||= (highlight[k] || result[k])
67
+ end
68
+ end
69
+
70
+ result["id"] ||= result["_id"] # needed for legacy reasons
71
+ Hashie::Mash.new(result)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ def suggestions
78
+ if response["suggest"]
79
+ response["suggest"].values.flat_map { |v| v.first["options"] }.sort_by { |o| -o["score"] }.map { |o| o["text"] }.uniq
80
+ else
81
+ raise "Pass `suggest: true` to the search method for suggestions"
82
+ end
83
+ end
84
+
85
+ def each_with_hit(&block)
86
+ results.zip(hits).each(&block)
87
+ end
88
+
89
+ def with_details
90
+ each_with_hit.map do |model, hit|
91
+ details = {}
92
+ if hit["highlight"]
93
+ details[:highlight] = Hash[hit["highlight"].map { |k, v| [(options[:json] ? k : k.sub(/\.#{@options[:match_suffix]}\z/, "")).to_sym, v.first] }]
94
+ end
95
+ [model, details]
96
+ end
97
+ end
98
+
99
+ def aggregations
100
+ response["aggregations"]
101
+ end
102
+
103
+ def aggs
104
+ @aggs ||= begin
105
+ if aggregations
106
+ aggregations.dup.each do |field, filtered_agg|
107
+ buckets = filtered_agg[field]
108
+ # move the buckets one level above into the field hash
109
+ if buckets
110
+ filtered_agg.delete(field)
111
+ filtered_agg.merge!(buckets)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ def took
119
+ response["took"]
120
+ end
121
+
122
+ def error
123
+ response["error"]
124
+ end
125
+
126
+ def model_name
127
+ klass.model_name
128
+ end
129
+
130
+ def entry_name
131
+ model_name.human.downcase
132
+ end
133
+
134
+ def total_count
135
+ response["hits"]["total"]
136
+ end
137
+ alias_method :total_entries, :total_count
138
+
139
+ def current_page
140
+ options[:page]
141
+ end
142
+
143
+ def per_page
144
+ options[:per_page]
145
+ end
146
+ alias_method :limit_value, :per_page
147
+
148
+ def padding
149
+ options[:padding]
150
+ end
151
+
152
+ def total_pages
153
+ (total_count / per_page.to_f).ceil
154
+ end
155
+ alias_method :num_pages, :total_pages
156
+
157
+ def offset_value
158
+ (current_page - 1) * per_page + padding
159
+ end
160
+ alias_method :offset, :offset_value
161
+
162
+ def previous_page
163
+ current_page > 1 ? (current_page - 1) : nil
164
+ end
165
+ alias_method :prev_page, :previous_page
166
+
167
+ def next_page
168
+ current_page < total_pages ? (current_page + 1) : nil
169
+ end
170
+
171
+ def first_page?
172
+ previous_page.nil?
173
+ end
174
+
175
+ def last_page?
176
+ next_page.nil?
177
+ end
178
+
179
+ def out_of_range?
180
+ current_page > total_pages
181
+ end
182
+
183
+ def hits
184
+ @response["hits"]["hits"]
185
+ end
186
+
187
+ def misspellings?
188
+ @options[:misspellings]
189
+ end
190
+
191
+ private
192
+
193
+ def results_query(records, hits)
194
+ ids = hits.map { |hit| hit["_id"] }
195
+
196
+ if options[:includes]
197
+ records =
198
+ if defined?(NoBrainer::Document) && records < NoBrainer::Document
199
+ if Gem.loaded_specs["nobrainer"].version >= Gem::Version.new("0.21")
200
+ records.eager_load(options[:includes])
201
+ else
202
+ records.preload(options[:includes])
203
+ end
204
+ else
205
+ records.includes(options[:includes])
206
+ end
207
+ end
208
+
209
+ Searchkick.load_records(records, ids)
210
+ end
211
+
212
+ def base_field(k)
213
+ k.sub(/\.(analyzed|word_start|word_middle|word_end|text_start|text_middle|text_end|exact)\z/, "")
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,33 @@
1
+ require "rake"
2
+
3
+ namespace :searchkick do
4
+ desc "reindex model"
5
+ task reindex: :environment do
6
+ if ENV["CLASS"]
7
+ klass = ENV["CLASS"].constantize rescue nil
8
+ if klass
9
+ klass.reindex
10
+ else
11
+ abort "Could not find class: #{ENV['CLASS']}"
12
+ end
13
+ else
14
+ abort "USAGE: rake searchkick:reindex CLASS=Product"
15
+ end
16
+ end
17
+
18
+ if defined?(Rails)
19
+
20
+ namespace :reindex do
21
+ desc "reindex all models"
22
+ task all: :environment do
23
+ Rails.application.eager_load!
24
+ Searchkick.models.each do |model|
25
+ puts "Reindexing #{model.name}..."
26
+ model.reindex
27
+ end
28
+ puts "Reindex complete"
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module Searchkick
2
+ VERSION = "2.3.0"
3
+ end
data/lib/searchkick.rb ADDED
@@ -0,0 +1,215 @@
1
+ require "active_model"
2
+ require "elasticsearch"
3
+ require "hashie"
4
+ require "searchkick/version"
5
+ require "searchkick/index_options"
6
+ require "searchkick/index"
7
+ require "searchkick/indexer"
8
+ require "searchkick/reindex_queue"
9
+ require "searchkick/results"
10
+ require "searchkick/query"
11
+ require "searchkick/model"
12
+ require "searchkick/tasks"
13
+ require "searchkick/middleware"
14
+ require "searchkick/logging" if defined?(ActiveSupport::Notifications)
15
+ require "active_support/core_ext/hash/deep_merge"
16
+
17
+ # background jobs
18
+ begin
19
+ require "active_job"
20
+ rescue LoadError
21
+ # do nothing
22
+ end
23
+ if defined?(ActiveJob)
24
+ require "searchkick/bulk_reindex_job"
25
+ require "searchkick/process_queue_job"
26
+ require "searchkick/process_batch_job"
27
+ require "searchkick/reindex_v2_job"
28
+ end
29
+
30
+ module Searchkick
31
+ class Error < StandardError; end
32
+ class MissingIndexError < Error; end
33
+ class UnsupportedVersionError < Error; end
34
+ class InvalidQueryError < Elasticsearch::Transport::Transport::Errors::BadRequest; end
35
+ class DangerousOperation < Error; end
36
+ class ImportError < Error; end
37
+
38
+ class << self
39
+ attr_accessor :search_method_name, :wordnet_path, :timeout, :models, :client_options, :redis, :index_suffix, :queue_name, :searchkick_search_analyzer, :searchkick_search2_analyzer
40
+ attr_writer :client, :env, :search_timeout
41
+ attr_reader :aws_credentials
42
+ end
43
+ self.search_method_name = :search
44
+ self.wordnet_path = "/var/lib/wn_s.pl"
45
+ self.timeout = 10
46
+ self.models = []
47
+ self.client_options = {}
48
+ self.queue_name = :searchkick
49
+ self.searchkick_search_analyzer = 'searchkick_search'
50
+ self.searchkick_search2_analyzer = 'searchkick_search2'
51
+
52
+ def self.client
53
+ @client ||= begin
54
+ require "typhoeus/adapters/faraday" if defined?(Typhoeus)
55
+
56
+ Elasticsearch::Client.new({
57
+ url: ENV["ELASTICSEARCH_URL"],
58
+ transport_options: {request: {timeout: timeout}, headers: {content_type: "application/json"}}
59
+ }.deep_merge(client_options)) do |f|
60
+ f.use Searchkick::Middleware
61
+ f.request :aws_signers_v4, {
62
+ credentials: Aws::Credentials.new(aws_credentials[:access_key_id], aws_credentials[:secret_access_key]),
63
+ service_name: "es",
64
+ region: aws_credentials[:region] || "us-east-1"
65
+ } if aws_credentials
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.env
71
+ @env ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
72
+ end
73
+
74
+ def self.search_timeout
75
+ @search_timeout || timeout
76
+ end
77
+
78
+ def self.server_version
79
+ @server_version ||= client.info["version"]["number"]
80
+ end
81
+
82
+ def self.server_below?(version)
83
+ Gem::Version.new(server_version.sub("-", ".")) < Gem::Version.new(version.sub("-", "."))
84
+ end
85
+
86
+ def self.search(term = "*", **options, &block)
87
+ klass = options[:model]
88
+
89
+ # TODO add in next major version
90
+ # if !klass
91
+ # index_name = Array(options[:index_name])
92
+ # if index_name.size == 1 && index_name.first.respond_to?(:searchkick_index)
93
+ # klass = index_name.first
94
+ # end
95
+ # end
96
+
97
+ query = Searchkick::Query.new(klass, term, options.except(:model))
98
+ block.call(query.body) if block
99
+ if options[:execute] == false
100
+ query
101
+ else
102
+ query.execute
103
+ end
104
+ end
105
+
106
+ def self.multi_search(queries)
107
+ if queries.any?
108
+ responses = client.msearch(body: queries.flat_map { |q| [q.params.except(:body), q.body] })["responses"]
109
+ queries.each_with_index do |query, i|
110
+ query.handle_response(responses[i])
111
+ end
112
+ end
113
+ queries
114
+ end
115
+
116
+ # callbacks
117
+
118
+ def self.enable_callbacks
119
+ self.callbacks_value = nil
120
+ end
121
+
122
+ def self.disable_callbacks
123
+ self.callbacks_value = false
124
+ end
125
+
126
+ def self.callbacks?
127
+ Thread.current[:searchkick_callbacks_enabled].nil? || Thread.current[:searchkick_callbacks_enabled]
128
+ end
129
+
130
+ def self.callbacks(value)
131
+ if block_given?
132
+ previous_value = callbacks_value
133
+ begin
134
+ self.callbacks_value = value
135
+ yield
136
+ indexer.perform if callbacks_value == :bulk
137
+ ensure
138
+ self.callbacks_value = previous_value
139
+ end
140
+ else
141
+ self.callbacks_value = value
142
+ end
143
+ end
144
+
145
+ def self.aws_credentials=(creds)
146
+ require "faraday_middleware/aws_signers_v4"
147
+ @aws_credentials = creds
148
+ @client = nil # reset client
149
+ end
150
+
151
+ def self.reindex_status(index_name)
152
+ if redis
153
+ batches_left = Searchkick::Index.new(index_name).batches_left
154
+ {
155
+ completed: batches_left == 0,
156
+ batches_left: batches_left
157
+ }
158
+ end
159
+ end
160
+
161
+ def self.with_redis
162
+ if redis
163
+ if redis.respond_to?(:with)
164
+ redis.with do |r|
165
+ yield r
166
+ end
167
+ else
168
+ yield redis
169
+ end
170
+ end
171
+ end
172
+
173
+ # private
174
+ def self.load_records(records, ids)
175
+ records =
176
+ if records.respond_to?(:primary_key)
177
+ # ActiveRecord
178
+ records.where(records.primary_key => ids) if records.primary_key
179
+ elsif records.respond_to?(:queryable)
180
+ # Mongoid 3+
181
+ records.queryable.for_ids(ids)
182
+ elsif records.respond_to?(:unscoped) && :id.respond_to?(:in)
183
+ # Nobrainer
184
+ records.unscoped.where(:id.in => ids)
185
+ elsif records.respond_to?(:key_column_names)
186
+ records.where(records.key_column_names.first => ids)
187
+ end
188
+
189
+ raise Searchkick::Error, "Not sure how to load records" if !records
190
+
191
+ records
192
+ end
193
+
194
+ # private
195
+ def self.indexer
196
+ Thread.current[:searchkick_indexer] ||= Searchkick::Indexer.new
197
+ end
198
+
199
+ # private
200
+ def self.callbacks_value
201
+ Thread.current[:searchkick_callbacks_enabled]
202
+ end
203
+
204
+ # private
205
+ def self.callbacks_value=(value)
206
+ Thread.current[:searchkick_callbacks_enabled] = value
207
+ end
208
+ end
209
+
210
+ # TODO find better ActiveModel hook
211
+ ActiveModel::Callbacks.send(:include, Searchkick::Model)
212
+
213
+ ActiveSupport.on_load(:active_record) do
214
+ extend Searchkick::Model
215
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "searchkick/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "searchkick-hooopo"
8
+ spec.version = Searchkick::VERSION
9
+ spec.authors = ["Andrew Kane"]
10
+ spec.email = ["andrew@chartkick.com"]
11
+ spec.description = "Intelligent search made easy"
12
+ spec.summary = "Searchkick learns what your users are looking for. As more people search, it gets smarter and the results get better. It’s friendly for developers - and magical for your users."
13
+ spec.homepage = "https://github.com/ankane/searchkick"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features|benchmark)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activemodel", ">= 4.1"
22
+ spec.add_dependency "elasticsearch", ">= 1"
23
+ spec.add_dependency "hashie"
24
+
25
+ spec.add_development_dependency "bundler"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "minitest"
28
+ end