elastic_record 0.11.1 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +2 -1
  3. data/README.rdoc +37 -9
  4. data/elastic_record.gemspec +2 -2
  5. data/lib/elastic_record/callbacks.rb +1 -1
  6. data/lib/elastic_record/connection.rb +36 -13
  7. data/lib/elastic_record/index.rb +33 -4
  8. data/lib/elastic_record/index/configurator.rb +14 -0
  9. data/lib/elastic_record/index/documents.rb +21 -13
  10. data/lib/elastic_record/index/manage.rb +4 -9
  11. data/lib/elastic_record/index/mapping.rb +12 -0
  12. data/lib/elastic_record/index/percolator.rb +1 -0
  13. data/lib/elastic_record/index/settings.rb +4 -0
  14. data/lib/elastic_record/lucene.rb +40 -22
  15. data/lib/elastic_record/model.rb +8 -5
  16. data/lib/elastic_record/relation/batches.rb +8 -1
  17. data/lib/elastic_record/relation/finder_methods.rb +2 -2
  18. data/lib/elastic_record/relation/none.rb +3 -3
  19. data/lib/elastic_record/relation/search_methods.rb +33 -3
  20. data/lib/elastic_record/relation/value_methods.rb +1 -1
  21. data/lib/elastic_record/searches_many.rb +4 -0
  22. data/lib/elastic_record/searches_many/association.rb +25 -14
  23. data/lib/elastic_record/searches_many/reflection.rb +1 -1
  24. data/lib/elastic_record/tasks/index.rake +5 -21
  25. data/test/elastic_record/callbacks_test.rb +9 -0
  26. data/test/elastic_record/connection_test.rb +19 -1
  27. data/test/elastic_record/index/configurator_test.rb +18 -0
  28. data/test/elastic_record/index/documents_test.rb +22 -3
  29. data/test/elastic_record/index/manage_test.rb +7 -0
  30. data/test/elastic_record/index/mapping_test.rb +11 -0
  31. data/test/elastic_record/index/percolator_test.rb +11 -9
  32. data/test/elastic_record/index_test.rb +23 -6
  33. data/test/elastic_record/lucene_test.rb +21 -13
  34. data/test/elastic_record/model_test.rb +9 -9
  35. data/test/elastic_record/relation/batches_test.rb +8 -0
  36. data/test/elastic_record/relation/finder_methods_test.rb +6 -7
  37. data/test/elastic_record/relation/none_test.rb +3 -0
  38. data/test/elastic_record/relation/search_methods_test.rb +17 -41
  39. data/test/elastic_record/relation_test.rb +2 -0
  40. data/test/elastic_record/searches_many/reflection_test.rb +7 -0
  41. metadata +15 -19
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 3a25b326c628b294aff17e44d2465d1b988ca2c3
4
+ data.tar.gz: f20002ac648ecf9f7df8f524bf639e6b1be55af2
5
+ SHA512:
6
+ metadata.gz: c3baffc104e19d488ae910b3265cd8dcb239ac5a1c05492cdbb1a7747bb2156f3e4085bd6de0defed0eea9dbd2b6b3478a17f759eeaf3a751e12d417f7912eef
7
+ data.tar.gz: 1bdeaefcffd51c750ad47d36ff5d8018d9040557f2f9a63d994715bb9206346c1439b1c2e8abcf09c997f127bf06fa6ccacccceef33798993f50548c4c5069db
data/Gemfile CHANGED
@@ -1,4 +1,5 @@
1
- source :rubygems
1
+ source 'https://rubygems.org'
2
+
2
3
  gemspec
3
4
 
4
5
  gem 'rake'
data/README.rdoc CHANGED
@@ -9,16 +9,38 @@ The usual Gemfile addition:
9
9
 
10
10
  gem 'elastic_record'
11
11
 
12
- Creating the index:
13
-
14
- rake index:create
15
12
 
16
13
  Include ElasticRecord into your model:
17
14
 
18
- ActiveSupport.on_load :active_record do
15
+ class Product < YourFavoriteOrm::Base
19
16
  include ElasticRecord::Model
20
17
  end
21
18
 
19
+ == Configuration
20
+
21
+ While elastic search automatically maps fields, you may wish to override the defaults:
22
+
23
+ class Product < YourFavoriteOrm::Base
24
+ elastic_index.configure do
25
+ property :status, type: "string", index: "not_analyzed"
26
+ end
27
+ end
28
+
29
+ You can also directly access Product.elastic_index.mapping and Product.elastic_index.settings:
30
+
31
+ class Product
32
+ elastic_index.mapping = {
33
+ properties: {
34
+ name: {type: "string", index: "analyzed"}
35
+ status: {type: "string", index: "not_analyzed"}
36
+ }
37
+ }
38
+ end
39
+
40
+ Create the index:
41
+
42
+ rake index:create
43
+
22
44
  == Searching
23
45
 
24
46
  ElasticRecord adds the method 'elastic_search' to your models. It works similar to active_record scoping:
@@ -29,8 +51,9 @@ ElasticRecord adds the method 'elastic_search' to your models. It works similar
29
51
 
30
52
  If a simple hash is passed into filter, a term or terms query is created:
31
53
 
32
- search.filter(color: 'red') # Creates a term filter
33
- search.filter(color: %w(red blue)) # Creates a terms filter
54
+ search.filter(color: 'red') # Creates a 'term' filter
55
+ search.filter(color: %w(red blue)) # Creates a 'terms' filter
56
+ search.filter(color: nil) # Creates a 'missing' filter
34
57
 
35
58
  If a hash containing hashes is passed into filter, it is used directly as a filter DSL expression:
36
59
 
@@ -47,9 +70,6 @@ An Arelastic object can also be passed in, working similarily to Arel:
47
70
  # Size is greater than 5
48
71
  search.filter(Product.arelastic[:size].gt(5))
49
72
 
50
- # Product has a nil name
51
- search.filter(Product.arelastic[:name].missing)
52
-
53
73
  # Name is 'hola' or name is missing
54
74
  search.filter(Product.arelastic[:name].eq("hola").or(Product.arelastic[:name].missing))
55
75
 
@@ -115,3 +135,11 @@ Class methods can be executed within scopes:
115
135
 
116
136
  # Increase the price of all red products by $10.
117
137
  Product.filter(color: 'red').increase_prices
138
+
139
+ == Index Information
140
+
141
+ Core and Index APIs can be accessed with Product.elastic_index. Some examples include:
142
+
143
+ Production.elastic_index.refresh # Call the refresh API
144
+ Production.elastic_index.get_mapping # Get the index mapping defined by elastic search
145
+ Production.elastic_index.reset # Delete related indexes and deploy a new one
@@ -1,8 +1,8 @@
1
1
  # -*- encoding: utf-8 -*-
2
-
2
+
3
3
  Gem::Specification.new do |s|
4
4
  s.name = 'elastic_record'
5
- s.version = '0.11.1'
5
+ s.version = '0.12.0'
6
6
  s.summary = 'Use Elastic Search with your objects'
7
7
  s.description = 'Find your records with elastic search'
8
8
 
@@ -2,7 +2,7 @@ module ElasticRecord
2
2
  module Callbacks
3
3
  def self.included(base)
4
4
  base.class_eval do
5
- after_save do
5
+ after_save if: :changed? do
6
6
  self.class.elastic_index.index_document id, as_search
7
7
  end
8
8
 
@@ -15,7 +15,7 @@ module ElasticRecord
15
15
  self.servers = servers.split(',')
16
16
  end
17
17
 
18
- self.current_server = choose_server
18
+ self.current_server = next_server
19
19
  self.request_count = 0
20
20
  self.max_request_count = 100
21
21
  self.options = options
@@ -60,27 +60,35 @@ module ElasticRecord
60
60
  }
61
61
 
62
62
  def http_request(method, path, body = nil)
63
- request = METHODS[method].new(path)
64
- request.body = body
65
- http = new_http
66
-
67
- ActiveSupport::Notifications.instrument("request.elastic_record") do |payload|
68
- payload[:http] = http
69
- payload[:request] = request
70
- payload[:response] = http.request(request)
63
+ with_retry do
64
+ request = METHODS[method].new(path)
65
+ request.body = body
66
+ http = new_http
67
+
68
+ ActiveSupport::Notifications.instrument("request.elastic_record") do |payload|
69
+ payload[:http] = http
70
+ payload[:request] = request
71
+ payload[:response] = http.request(request)
72
+ end
71
73
  end
72
74
  end
73
75
 
74
76
  private
75
- def choose_server
76
- servers.sample
77
+ def next_server
78
+ if @shuffled_servers.nil?
79
+ @shuffled_servers = servers.shuffle
80
+ else
81
+ @shuffled_servers.push(@shuffled_servers.shift)
82
+ end
83
+
84
+ @shuffled_servers.first
77
85
  end
78
86
 
79
87
  def new_http
80
88
  self.request_count += 1
81
89
 
82
- if request_count > max_request_count{}
83
- self.current_server = choose_server
90
+ if request_count > max_request_count
91
+ self.current_server = next_server
84
92
  self.request_count = 0
85
93
  end
86
94
 
@@ -92,5 +100,20 @@ module ElasticRecord
92
100
  end
93
101
  http
94
102
  end
103
+
104
+ def with_retry
105
+ retry_count = 0
106
+ begin
107
+ yield
108
+ rescue StandardError
109
+ if retry_count < options[:retries].to_i
110
+ self.current_server = next_server
111
+ retry_count += 1
112
+ retry
113
+ else
114
+ raise
115
+ end
116
+ end
117
+ end
95
118
  end
96
119
  end
@@ -1,12 +1,31 @@
1
+ require 'elastic_record/index/configurator'
1
2
  require 'elastic_record/index/deferred'
2
3
  require 'elastic_record/index/documents'
3
4
  require 'elastic_record/index/manage'
4
5
  require 'elastic_record/index/mapping'
5
6
  require 'elastic_record/index/percolator'
6
7
  require 'elastic_record/index/settings'
7
- require 'net/http'
8
+
9
+ require 'active_support/core_ext/hash/deep_dup'
8
10
 
9
11
  module ElasticRecord
12
+ # ElasticRecord::Index provides access to elastic search's API. It is accessed with
13
+ # <tt>Widget.elastic_index</tt>. The methods provided are:
14
+ #
15
+ # [create]
16
+ # Create a new index that is not aliased
17
+ # [create_and_deploy]
18
+ # Create a new index and alias it
19
+ # [reset]
20
+ # Delete all aliased indexes and deploy a new one
21
+ # [refresh]
22
+ # Call the refresh API
23
+ # [exists?(index_name)]
24
+ # Returns if the index exists
25
+ # [get_mapping]
26
+ # Returns the mapping currently stored by elastic search.
27
+ # [put_mapping]
28
+ # Update elastic search's mapping
10
29
  class Index
11
30
  include Documents
12
31
  include Manage
@@ -23,8 +42,10 @@ module ElasticRecord
23
42
  @disabled = false
24
43
  end
25
44
 
26
- # def initialize_copy(other)
27
- # end
45
+ def initialize_copy(other)
46
+ @settings = settings.deep_dup
47
+ @mapping = mapping.deep_dup
48
+ end
28
49
 
29
50
  def alias_name
30
51
  @alias_name ||= model.base_class.model_name.collection
@@ -46,9 +67,17 @@ module ElasticRecord
46
67
  model.elastic_connection
47
68
  end
48
69
 
70
+ def configure(&block)
71
+ Configurator.new(self).instance_eval(&block)
72
+ end
73
+
74
+ def get(end_path, json = nil)
75
+ connection.json_get "/#{alias_name}/#{type}/#{end_path}", json
76
+ end
77
+
49
78
  private
50
79
  def new_index_name
51
- "#{alias_name}_#{Time.now.to_i}"
80
+ "#{alias_name}_#{(Time.now.to_f * 100).to_i}"
52
81
  end
53
82
  end
54
83
  end
@@ -0,0 +1,14 @@
1
+ module ElasticRecord
2
+ class Index
3
+ class Configurator
4
+ attr_reader :index
5
+ def initialize(index)
6
+ @index = index
7
+ end
8
+
9
+ def property(name, options)
10
+ index.mapping[:properties][name.to_sym] = options
11
+ end
12
+ end
13
+ end
14
+ end
@@ -8,9 +8,9 @@ module ElasticRecord
8
8
 
9
9
  index_name ||= alias_name
10
10
 
11
- if @batch
12
- @batch << { index: { _index: index_name, _type: type, _id: id } }
13
- @batch << document
11
+ if batch = current_batch
12
+ current_batch << { index: { _index: index_name, _type: type, _id: id } }
13
+ current_batch << document
14
14
  else
15
15
  connection.json_put "/#{index_name}/#{type}/#{id}", document
16
16
  end
@@ -19,28 +19,28 @@ module ElasticRecord
19
19
  def delete_document(id, index_name = nil)
20
20
  index_name ||= alias_name
21
21
 
22
- if @batch
23
- @batch << { delete: { _index: index_name, _type: type, _id: id } }
22
+ if batch = current_batch
23
+ batch << { delete: { _index: index_name, _type: type, _id: id } }
24
24
  else
25
25
  connection.json_delete "/#{index_name}/#{type}/#{id}"
26
26
  end
27
27
  end
28
28
 
29
29
  def record_exists?(id)
30
- connection.json_get("/#{alias_name}/#{type}/#{id}")['exists']
30
+ get(id)['exists']
31
31
  end
32
32
 
33
33
  def search(elastic_query, options = {})
34
- url = "/#{alias_name}/#{type}/_search"
34
+ url = "_search"
35
35
  if options.any?
36
36
  url += "?#{options.to_query}"
37
37
  end
38
38
 
39
- connection.json_get url, elastic_query
39
+ get url, elastic_query
40
40
  end
41
41
 
42
42
  def explain(id, elastic_query)
43
- connection.json_get "/#{alias_name}/#{type}/#{id}/_explain", elastic_query
43
+ get "_explain", elastic_query
44
44
  end
45
45
 
46
46
  def scroll(scroll_id, scroll_keep_alive)
@@ -49,14 +49,14 @@ module ElasticRecord
49
49
  end
50
50
 
51
51
  def bulk
52
- @batch = []
52
+ @_batch = []
53
53
  yield
54
- if @batch.any?
55
- body = @batch.map { |action| "#{ActiveSupport::JSON.encode(action)}\n" }.join
54
+ if @_batch.any?
55
+ body = @_batch.map { |action| "#{ActiveSupport::JSON.encode(action)}\n" }.join
56
56
  connection.json_post "/_bulk", body
57
57
  end
58
58
  ensure
59
- @batch = nil
59
+ @_batch = nil
60
60
  end
61
61
 
62
62
  def bulk_add(batch, index_name = nil)
@@ -68,6 +68,14 @@ module ElasticRecord
68
68
  end
69
69
  end
70
70
  end
71
+
72
+ def current_batch
73
+ if @_batch
74
+ @_batch
75
+ elsif model.superclass.respond_to?(:elastic_index)
76
+ model.superclass.elastic_index.current_batch
77
+ end
78
+ end
71
79
  end
72
80
  end
73
81
  end
@@ -9,7 +9,6 @@ module ElasticRecord
9
9
 
10
10
  def create(index_name = new_index_name)
11
11
  connection.json_put "/#{index_name}", "settings" => settings
12
- # update_settings(index_name)
13
12
  update_mapping(index_name)
14
13
  index_name
15
14
  end
@@ -28,6 +27,10 @@ module ElasticRecord
28
27
  connection.head("/#{index_name}") == '200'
29
28
  end
30
29
 
30
+ def type_exists?(index_name = alias_name)
31
+ connection.head("/#{index_name}/#{type}") == '200'
32
+ end
33
+
31
34
  def deploy(index_name)
32
35
  actions = [
33
36
  {
@@ -50,14 +53,6 @@ module ElasticRecord
50
53
  connection.json_post '/_aliases', actions: actions
51
54
  end
52
55
 
53
- def update_mapping(index_name = alias_name)
54
- connection.json_put "/#{index_name}/#{type}/_mapping", type => mapping
55
- end
56
-
57
- def update_settings(index_name = alias_name)
58
- connection.json_put "/#{index_name}/_settings", settings
59
- end
60
-
61
56
  def refresh(index_name = alias_name)
62
57
  connection.json_post "/#{index_name}/_refresh"
63
58
  end
@@ -5,6 +5,18 @@ module ElasticRecord
5
5
  mapping.deep_merge!(mapping)
6
6
  end
7
7
 
8
+ def update_mapping(index_name = alias_name)
9
+ connection.json_put "/#{index_name}/#{type}/_mapping", type => mapping
10
+ end
11
+
12
+ def get_mapping(index_name = alias_name)
13
+ connection.json_get "/#{index_name}/#{type}/_mapping"
14
+ end
15
+
16
+ def delete_mapping(index_name = alias_name)
17
+ connection.json_delete "/#{index_name}/#{type}"
18
+ end
19
+
8
20
  def mapping
9
21
  @mapping ||= {
10
22
  _source: {
@@ -5,6 +5,7 @@ module ElasticRecord
5
5
  unless exists? percolator_index_name
6
6
  create percolator_index_name
7
7
  else
8
+ delete_mapping(percolator_index_name) if type_exists?(percolator_index_name)
8
9
  update_mapping percolator_index_name
9
10
  end
10
11
 
@@ -8,6 +8,10 @@ module ElasticRecord
8
8
  def settings
9
9
  @settings ||= {}
10
10
  end
11
+
12
+ def update_settings(index_name = alias_name)
13
+ connection.json_put "/#{index_name}/_settings", settings
14
+ end
11
15
  end
12
16
  end
13
17
  end
@@ -4,47 +4,65 @@ module ElasticRecord
4
4
  class Lucene
5
5
  # Special characters:
6
6
  # + - && || ! ( ) { } [ ] ^ " ~ * ? : \
7
- ESCAPE_REGEX = /(\+|-|&&|\|\||!|\(|\)|{|}|\[|\]|`|"|~|\?|:|\\)/
7
+ ESCAPE_REGEX = /(\+|-|&&|\|\||!|\(|\)|{|}|\[|\]|`|"|~\*|\?|:|\\)/
8
8
 
9
9
  class << self
10
10
  def escape(query)
11
11
  query.gsub(ESCAPE_REGEX, "\\\\\\1")
12
12
  end
13
13
 
14
- def query_words(query)
15
- Shellwords::shellwords query.gsub("'", "\"'\"")
16
- end
17
-
18
- # Returns a lucene query that works like G
19
- def smart_query(query, fields, &block)
14
+ # Returns a lucene query that works like GMail
15
+ def match_phrase(query, fields, &block)
20
16
  return if query.blank?
21
17
 
22
- words = query_words(query)
18
+ words = split_phrase_into_words(query)
23
19
 
24
20
  words.map do |word|
25
21
  if word =~ /^(\w+):(.+)$/ && fields.include?($1)
26
- match_word $2, [$1], &block
22
+ match_word $2, [block_given? ? yield($1) : $1]
27
23
  else
28
- match_word word, fields, &block
24
+ match_word word, (block_given? ? fields.map(&block) : fields)
29
25
  end
30
26
  end.join(' AND ')
31
27
  end
32
28
 
33
- private
29
+ # Performs a prefix match on the word:
30
+ #
31
+ # ElasticRecord::Lucene.match_word('blue', ['color', 'name'])
32
+ # => (color:blue* OR name:blue*)
33
+ #
34
+ # In the case that the word has special characters, it is wrapped in quotes:
35
+ #
36
+ # ElasticRecord::Lucene.match_word('A&M', ['name'])
37
+ # => (name:"A&M")
38
+ def match_word(word, fields)
39
+ if word =~ / / || word =~ ESCAPE_REGEX
40
+ word = "\"#{word.gsub('"', '')}\""
41
+ else
42
+ word = "#{word}*"
43
+ end
34
44
 
35
- def match_word(word, fields, &block)
36
- if word =~ / / || word =~ ESCAPE_REGEX
37
- word = "\"#{word}\""
38
- else
39
- word = "#{word}*"
40
- end
45
+ or_query = fields.map do |field|
46
+ "#{field}:#{word}"
47
+ end.join(' OR ')
48
+
49
+ "(#{or_query})"
50
+ end
41
51
 
42
- or_query = fields.map do |field|
43
- field = yield(field) if block_given?
44
- "#{field}:#{word}"
45
- end.join(' OR ')
52
+ private
53
+ # Converts a sentence into the words:
54
+ #
55
+ # split_phrase_into_words('his "blue fox"')
56
+ # => ['his', 'blue fox']
57
+ def split_phrase_into_words(phrase)
58
+ # If we have an odd number of double quotes,
59
+ # add a double quote to the end so that shellwords
60
+ # does not crap out.
61
+ if phrase.count('"') % 2 == 1
62
+ phrase = "#{phrase}\""
63
+ end
46
64
 
47
- "(#{or_query})"
65
+ Shellwords::shellwords phrase.gsub("'", "\"'\"")
48
66
  end
49
67
  end
50
68
  end