chewy 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,14 +1,83 @@
1
+ require 'i18n/core_ext/hash'
2
+
3
+ # Rspec matcher `update_index`
4
+ # To use it - add `require 'chewy/rspec'` to the `spec_helper.rb`
5
+ # Simple usage - just pass type as argument.
6
+ #
7
+ # specify { expect { user.save! }.to update_index(UsersIndex::User) }
8
+ # specify { expect { user.save! }.to update_index('users#user') }
9
+ # specify { expect { user.save! }.not_to update_index('users#user') }
10
+ #
11
+ # This example will pass as well because user1 was reindexed
12
+ # and nothing was said about user2:
13
+ #
14
+ # specify { expect { [user1, user2].map(&:save!) }
15
+ # .to update_index(UsersIndex.user).and_reindex(user1) }
16
+ #
17
+ # If you need to specify reindexed records strictly - use `only` chain.
18
+ # Combined matcher chain methods:
19
+ #
20
+ # specify { expect { user1.destroy!; user2.save! } }
21
+ # .to update_index(UsersIndex:User).and_reindex(user2).and_delete(user1)
22
+ #
1
23
  RSpec::Matchers.define :update_index do |type_name, options = {}|
24
+
25
+ # Specify indexed records by passing record itself or id.
26
+ #
27
+ # specify { expect { user.save! }.to update_index(UsersIndex::User).and_reindex(user)
28
+ # specify { expect { user.save! }.to update_index(UsersIndex::User).and_reindex(42)
29
+ # specify { expect { [user1, user2].map(&:save!) }
30
+ # .to update_index(UsersIndex::User).and_reindex(user1, user2) }
31
+ # specify { expect { [user1, user2].map(&:save!) }
32
+ # .to update_index(UsersIndex::User).and_reindex(user1).and_reindex(user2) }
33
+ #
34
+ # Specify indexing count for every particular record. Useful in case
35
+ # urgent index updates.
36
+ #
37
+ # specify { expect { 2.times { user.save! } }
38
+ # .to update_index(UsersIndex::User).and_reindex(user, times: 2) }
39
+ #
40
+ # Specify reindexed attributes. Note that arrays are
41
+ # compared position-independantly.
42
+ #
43
+ # specify { expect { user.update_attributes!(name: 'Duke') }
44
+ # .to update_index(UsersIndex.user).and_reindex(user, with: {name: 'Duke'}) }
45
+ #
46
+ # You can combine all the options and chain `and_reindex` method to
47
+ # specify options for every indexed record:
48
+ #
49
+ # specify { expect { 2.times { [user1, user2].map { |u| u.update_attributes!(name: "Duke#{u.id}") } } }
50
+ # .to update_index(UsersIndex.user)
51
+ # .and_reindex(user1, with: {name: 'Duke42'}) }
52
+ # .and_reindex(user2, times: 1, with: {name: 'Duke43'}) }
53
+ #
2
54
  chain(:and_reindex) do |*args|
3
55
  @reindex ||= {}
4
56
  @reindex.merge!(extract_documents(*args))
5
57
  end
6
58
 
59
+ # Specify deleted records with record itself or id passed.
60
+ #
61
+ # specify { expect { user.destroy! }.to update_index(UsersIndex::User).and_delete(user) }
62
+ # specify { expect { user.destroy! }.to update_index(UsersIndex::User).and_delete(user.id) }
63
+ #
7
64
  chain(:and_delete) do |*args|
8
65
  @delete ||= {}
9
66
  @delete.merge!(extract_documents(*args))
10
67
  end
11
68
 
69
+ # Used for specifying than no other records would be indexed or deleted:
70
+ #
71
+ # specify { expect { [user1, user2].map(&:save!) }
72
+ # .to update_index(UsersIndex.user).and_reindex(user1, user2).only }
73
+ # specify { expect { [user1, user2].map(&:destroy!) }
74
+ # .to update_index(UsersIndex.user).and_delete(user1, user2).only }
75
+ #
76
+ # This example will fail:
77
+ #
78
+ # specify { expect { [user1, user2].map(&:save!) }
79
+ # .to update_index(UsersIndex.user).and_reindex(user1).only }
80
+ #
12
81
  chain(:only) do |*args|
13
82
  @only = true
14
83
  end
@@ -23,9 +92,8 @@ RSpec::Matchers.define :update_index do |type_name, options = {}|
23
92
  updated = []
24
93
  type.stub(:bulk) do |options|
25
94
  updated += options[:body].map do |updated_document|
26
- updated_document = updated_document.symbolize_keys
95
+ updated_document = updated_document.deep_symbolize_keys
27
96
  body = updated_document[:index] || updated_document[:delete]
28
- body[:data] = body[:data].symbolize_keys if body[:data]
29
97
  updated_document
30
98
  end
31
99
  {}
@@ -59,7 +127,7 @@ RSpec::Matchers.define :update_index do |type_name, options = {}|
59
127
  document[:match_count] = (!document[:expected_count] && document[:real_count] > 0) ||
60
128
  (document[:expected_count] && document[:expected_count] == document[:real_count])
61
129
  document[:match_attributes] = document[:expected_attributes].blank? ||
62
- document[:real_attributes].slice(*document[:expected_attributes].keys) == document[:expected_attributes]
130
+ compare_attributes(document[:expected_attributes], document[:real_attributes])
63
131
  end
64
132
  @delete.each do |_, document|
65
133
  document[:match_count] = (!document[:expected_count] && document[:real_count] > 0) ||
@@ -123,7 +191,7 @@ RSpec::Matchers.define :update_index do |type_name, options = {}|
123
191
  options = args.extract_options!
124
192
 
125
193
  expected_count = options[:times] || options[:count]
126
- expected_attributes = (options[:with] || options[:attributes] || {}).symbolize_keys!
194
+ expected_attributes = (options[:with] || options[:attributes] || {}).deep_symbolize_keys
127
195
 
128
196
  Hash[args.flatten.map do |document|
129
197
  id = document.respond_to?(:id) ? document.id.to_s : document.to_s
@@ -136,4 +204,27 @@ RSpec::Matchers.define :update_index do |type_name, options = {}|
136
204
  }]
137
205
  end]
138
206
  end
207
+
208
+ def compare_attributes expected, real
209
+ expected.inject(true) do |result, (key, value)|
210
+ equal = if value.is_a?(Array) && real[key].is_a?(Array)
211
+ array_difference(value, real[key]) && array_difference(real[key], value)
212
+ elsif value.is_a?(Hash) && real[key].is_a?(Hash)
213
+ compare_attributes(value, real[key])
214
+ else
215
+ real[key] == value
216
+ end
217
+ result && equal
218
+ end
219
+ end
220
+
221
+ def array_difference first, second
222
+ difference = first.to_ary.dup
223
+ second.to_ary.each do |element|
224
+ if index = difference.index(element)
225
+ difference.delete_at(index)
226
+ end
227
+ end
228
+ difference.none?
229
+ end
139
230
  end
@@ -36,6 +36,16 @@ module Chewy
36
36
  #
37
37
  # <tt>:batch_size</tt> - import batch size, 1000 objects by default
38
38
  #
39
+ # Method handles destroyed objects as well. In case of objects AcriveRecord::Relation
40
+ # or array passed, objects, responding with true to `destroyed?` method will be deleted
41
+ # from index. In case of ids array passed - documents with missing records ids will be
42
+ # deleted from index:
43
+ #
44
+ # users = User.all
45
+ # users.each { |user| user.destroy if user.incative? }
46
+ # UsersIndex::User.import users # inactive users will be deleted from index
47
+ # UsersIndex::User.import users.map(&:id) # deleted user ids will be deleted from index
48
+ #
39
49
  def import *args, &block
40
50
  import_options = args.extract_options!
41
51
  import_options[:batch_size] ||= BATCH_SIZE
@@ -65,7 +75,7 @@ module Chewy
65
75
 
66
76
  additional_scope = load_options[load_options[:_type].type_name.to_sym].try(:[], :scope) || load_options[:scope]
67
77
 
68
- scope = model.where(id: objects.map(&:id))
78
+ scope = scoped_model(objects.map(&:id))
69
79
  loaded_objects = if additional_scope.is_a?(Proc)
70
80
  scope.instance_exec(&additional_scope)
71
81
  elsif additional_scope.is_a?(::ActiveRecord::Relation)
@@ -85,7 +95,7 @@ module Chewy
85
95
  ids = ids.map(&:to_i).uniq
86
96
 
87
97
  indexed = true
88
- merged_scope(model.where(id: ids)).find_in_batches(import_options.slice(:batch_size)) do |objects|
98
+ merged_scope(scoped_model(ids)).find_in_batches(import_options.slice(:batch_size)) do |objects|
89
99
  ids -= objects.map(&:id)
90
100
  indexed &= block.call index: objects
91
101
  end
@@ -107,6 +117,10 @@ module Chewy
107
117
  scope ? scope.clone.merge(target) : target
108
118
  end
109
119
 
120
+ def scoped_model(ids)
121
+ model.where(Hash[model.primary_key.to_sym || :id, ids])
122
+ end
123
+
110
124
  def model_all
111
125
  ::ActiveRecord::VERSION::MAJOR < 4 ? model.scoped : model.all
112
126
  end
@@ -17,7 +17,9 @@ module Chewy
17
17
  #
18
18
  def import *args
19
19
  import_options = args.extract_options!
20
- bulk_options = import_options.extract!(:refresh, :suffix).reverse_merge!(refresh: true)
20
+ bulk_options = import_options.reject { |k, v| ![:refresh, :suffix].include?(k) }.reverse_merge!(refresh: true)
21
+
22
+ index.create!(bulk_options.slice(:suffix)) unless index.exists?
21
23
 
22
24
  ActiveSupport::Notifications.instrument 'import_objects.chewy', type: self do |payload|
23
25
  adapter.import(*args, import_options) do |action_objects|
@@ -57,6 +59,9 @@ module Chewy
57
59
  # Adds `:suffix` option to bulk import to index with specified suffix.
58
60
  def bulk options = {}
59
61
  suffix = options.delete(:suffix)
62
+
63
+ Chewy.wait_for_status
64
+
60
65
  result = client.bulk options.merge(index: index.build_index_name(suffix: suffix), type: type_name)
61
66
 
62
67
  extract_errors result
@@ -5,17 +5,111 @@ module Chewy
5
5
 
6
6
  included do
7
7
  class_attribute :root_object, instance_reader: false, instance_writer: false
8
+ class_attribute :_templates
8
9
  end
9
10
 
10
11
  module ClassMethods
11
- def root(options = {}, &block)
12
- raise "Root is already defined" if self.root_object
12
+ # Defines root object for mapping and is optional for type
13
+ # definition. Use it only if you need to pass options for root
14
+ # object mapping, such as `date_detection` or `dynamic_date_formats`
15
+ #
16
+ # class UsersIndex < Chewy::Index
17
+ # define_type User do
18
+ # # root object defined implicitly and optionless for current type
19
+ # field :full_name, type: 'string'
20
+ # end
21
+ # end
22
+ #
23
+ # class CarsIndex < Chewy::Index
24
+ # define_type Car do
25
+ # # explicit root definition with additional options
26
+ # root dynamic_date_formats: ['yyyy-MM-dd'] do
27
+ # field :model_name, type: 'string'
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ def root options = {}, &block
33
+ raise "Root is already defined" if root_object
13
34
  build_root(options, &block)
14
35
  end
15
36
 
16
- def field(*args, &block)
37
+ # Defines mapping field for current type
38
+ #
39
+ # class UsersIndex < Chewy::Index
40
+ # define_type User do
41
+ # # passing all the options to field definition:
42
+ # field :full_name, type: 'string', analyzer: 'special'
43
+ # end
44
+ # end
45
+ #
46
+ # The `type` is optional and defaults to `string` if not defined:
47
+ #
48
+ # field :full_name
49
+ #
50
+ # Also, multiple fields might be defined with one call and
51
+ # with the same options:
52
+ #
53
+ # field :first_name, :last_name, analyzer: 'special'
54
+ #
55
+ # The only special option in the field definition
56
+ # is `:value`. If no `:value` specified then just corresponding
57
+ # method will be called for the indexed object. Also
58
+ # `:value` might be a proc or indexed object method name:
59
+ #
60
+ # class User < ActiveRecord::Base
61
+ # def user_full_name
62
+ # [first_name, last_name].join(' ')
63
+ # end
64
+ # end
65
+ #
66
+ # field :full_name, type: 'string', value: :user_full_name
67
+ #
68
+ # The proc evaluates inside the indexed object context if
69
+ # its arity is 0 and in present contexts if there is an argument:
70
+ #
71
+ # field :full_name, type: 'string', value: -> { [first_name, last_name].join(' ') }
72
+ #
73
+ # separator = ' '
74
+ # field :full_name, type: 'string', value: ->(user) { [user.first_name, user.last_name].join(separator) }
75
+ #
76
+ # If array was returned as value - it will be put in index as well.
77
+ #
78
+ # field :tags, type: 'string', value: -> { tags.map(&:name) }
79
+ #
80
+ # Fields supports nesting in case of `object` field type. If
81
+ # `user.quiz` will return an array of objects, then result index content
82
+ # will be an array of hashes, if `user.quiz` is not a collection association
83
+ # then just values hash will be put in the index.
84
+ #
85
+ # field :quiz, type: 'object' do
86
+ # field :question, :answer
87
+ # field :score, type: 'integer'
88
+ # end
89
+ #
90
+ # Nested fields are composed from nested objects:
91
+ #
92
+ # field :name, type: 'object', value: -> { name_translations } do
93
+ # field :ru, value: ->(name) { name['ru'] }
94
+ # field :en, value: ->(name) { name['en'] }
95
+ # end
96
+ #
97
+ # Off course it is possible to define object fields contents dynamically
98
+ # but make sure evaluation proc returns hash:
99
+ #
100
+ # field :name, type: 'object', value: -> { name_translations }
101
+ #
102
+ # The special case is `multi_field`. In that case field composition
103
+ # changes satisfy elasticsearch rules:
104
+ #
105
+ # field :full_name, type: 'multi_field', value: ->{ full_name.try(:strip) } do
106
+ # field :full_name, index: 'analyzed', analyzer: 'name'
107
+ # field :sorted, index: 'analyzed', analyzer: 'sorted'
108
+ # end
109
+ #
110
+ def field *args, &block
17
111
  options = args.extract_options!
18
- build_root unless self.root_object
112
+ build_root unless root_object
19
113
 
20
114
  if args.size > 1
21
115
  args.map { |name| field(name, options) }
@@ -24,13 +118,42 @@ module Chewy
24
118
  end
25
119
  end
26
120
 
121
+ # Defines dynamic template in mapping root objests
122
+ #
123
+ # class CarsIndex < Chewy::Index
124
+ # define_type Car do
125
+ # template 'model.*', type: 'string', analyzer: 'special'
126
+ # field 'model', type: 'object' # here we can put { ru: 'Мерседес', en: 'Mercedes' }
127
+ # # and template will be applyed to this field
128
+ # end
129
+ # end
130
+ #
131
+ # Name for each template is generated with the following
132
+ # rule: "template_#{dynamic_templates.size + 1}".
133
+ #
134
+ # template 'tit*', mapping_hash
135
+ # template 'title.*', mapping_hash # dot in template causes "path_match" using
136
+ # template /tit.+/, mapping_hash # using "match_pattern": "regexp"
137
+ # template /title\..+/, mapping_hash # "\." - escaped dot causes "path_match" using
138
+ # template /tit.+/, 'string' mapping_hash # "match_mapping_type" as the optionsl second argument
139
+ # template template42: {match: 'hello*', mapping: {type: 'object'}} # or even pass a template as is
140
+ #
141
+ def template *args
142
+ build_root unless root_object
143
+
144
+ root_object.dynamic_template *args
145
+ end
146
+ alias_method :dynamic_template, :template
147
+
148
+ # Returns compiled mappings hash for current type
149
+ #
27
150
  def mappings_hash
28
151
  root_object ? root_object.mappings_hash : {}
29
152
  end
30
153
 
31
154
  private
32
155
 
33
- def expand_nested(field, &block)
156
+ def expand_nested field, &block
34
157
  @_current_field.nested(field) if @_current_field
35
158
  if block
36
159
  previous_field, @_current_field = @_current_field, field
@@ -39,7 +162,7 @@ module Chewy
39
162
  end
40
163
  end
41
164
 
42
- def build_root(options = {}, &block)
165
+ def build_root options = {}, &block
43
166
  self.root_object = Chewy::Fields::Root.new(type_name, options)
44
167
  expand_nested(self.root_object, &block)
45
168
  @_current_field = self.root_object
@@ -9,6 +9,9 @@ module Chewy
9
9
  method = args.first
10
10
 
11
11
  update = Proc.new do
12
+ update_options = options.reverse_merge(urgent: Chewy.urgent_update)
13
+ clear_association_cache if update_options[:urgent]
14
+
12
15
  backreference = if method && method.to_s == 'self'
13
16
  self
14
17
  elsif method
@@ -17,8 +20,7 @@ module Chewy
17
20
  instance_eval(&block)
18
21
  end
19
22
 
20
- Chewy.derive_type(type_name).update_index(backreference,
21
- options.reverse_merge(urgent: Chewy.urgent_update))
23
+ Chewy.derive_type(type_name).update_index(backreference, update_options)
22
24
  end
23
25
 
24
26
  after_save &update
data/lib/chewy/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Chewy
2
- VERSION = '0.3.0'
2
+ VERSION = '0.4.0'
3
3
  end
@@ -3,4 +3,42 @@ require 'spec_helper'
3
3
  describe Chewy::Fields::Root do
4
4
  specify { described_class.new('name').value.should be_a(Proc) }
5
5
  # TODO: add 'should_behave_like base_field'
6
+
7
+ subject(:field) { described_class.new('product') }
8
+
9
+ describe '#dynamic_template' do
10
+ specify do
11
+ field.dynamic_template 'hello', type: 'string'
12
+ field.dynamic_template 'hello*', :integer
13
+ field.dynamic_template 'hello.*'
14
+ field.dynamic_template /hello/
15
+ field.dynamic_template /hello.*/
16
+ field.dynamic_template template_42: {mapping: {}, match: ''}
17
+ field.dynamic_template /hello\..*/
18
+
19
+ field.mappings_hash.should == {product: {dynamic_templates: [
20
+ {template_1: {mapping: {type: 'string'}, match: 'hello'}},
21
+ {template_2: {mapping: {}, match_mapping_type: 'integer', match: 'hello*'}},
22
+ {template_3: {mapping: {}, path_match: 'hello.*'}},
23
+ {template_4: {mapping: {}, match: 'hello', match_pattern: 'regexp'}},
24
+ {template_5: {mapping: {}, match: 'hello.*', match_pattern: 'regexp'}},
25
+ {template_42: {mapping: {}, match: ''}},
26
+ {template_7: {mapping: {}, path_match: 'hello\..*', match_pattern: 'regexp'}}
27
+ ]}}
28
+ end
29
+
30
+ context do
31
+ subject(:field) { described_class.new('product', dynamic_templates: [
32
+ {template_42: {mapping: {}, match: ''}}
33
+ ]) }
34
+
35
+ specify do
36
+ field.dynamic_template 'hello', type: 'string'
37
+ field.mappings_hash.should == {product: {dynamic_templates: [
38
+ {template_42: {mapping: {}, match: ''}},
39
+ {template_1: {mapping: {type: 'string'}, match: 'hello'}}
40
+ ]}}
41
+ end
42
+ end
43
+ end
6
44
  end
@@ -284,7 +284,7 @@ describe Chewy::Index::Actions do
284
284
  define_type City do
285
285
  field :name, type: 'object'
286
286
  end
287
- end.tap(&:create!)
287
+ end
288
288
  end
289
289
 
290
290
  specify { CitiesIndex.import(city: dummy_cities).should == false }
@@ -308,7 +308,7 @@ describe Chewy::Index::Actions do
308
308
  define_type City do
309
309
  field :name, type: 'object'
310
310
  end
311
- end.tap(&:create!)
311
+ end
312
312
  end
313
313
 
314
314
  specify { expect { CitiesIndex.import!(city: dummy_cities) }.to raise_error Chewy::FailedImport }