elastictastic 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. data/LICENSE +19 -0
  2. data/README.md +326 -0
  3. data/lib/elastictastic/association.rb +21 -0
  4. data/lib/elastictastic/bulk_persistence_strategy.rb +70 -0
  5. data/lib/elastictastic/callbacks.rb +30 -0
  6. data/lib/elastictastic/child_collection_proxy.rb +56 -0
  7. data/lib/elastictastic/client.rb +101 -0
  8. data/lib/elastictastic/configuration.rb +35 -0
  9. data/lib/elastictastic/dirty.rb +130 -0
  10. data/lib/elastictastic/discrete_persistence_strategy.rb +52 -0
  11. data/lib/elastictastic/document.rb +98 -0
  12. data/lib/elastictastic/errors.rb +7 -0
  13. data/lib/elastictastic/field.rb +38 -0
  14. data/lib/elastictastic/index.rb +19 -0
  15. data/lib/elastictastic/mass_assignment_security.rb +15 -0
  16. data/lib/elastictastic/middleware.rb +119 -0
  17. data/lib/elastictastic/nested_document.rb +29 -0
  18. data/lib/elastictastic/new_relic_instrumentation.rb +26 -0
  19. data/lib/elastictastic/observer.rb +3 -0
  20. data/lib/elastictastic/observing.rb +21 -0
  21. data/lib/elastictastic/parent_child.rb +115 -0
  22. data/lib/elastictastic/persistence.rb +67 -0
  23. data/lib/elastictastic/properties.rb +236 -0
  24. data/lib/elastictastic/railtie.rb +35 -0
  25. data/lib/elastictastic/resource.rb +4 -0
  26. data/lib/elastictastic/scope.rb +283 -0
  27. data/lib/elastictastic/scope_builder.rb +32 -0
  28. data/lib/elastictastic/scoped.rb +20 -0
  29. data/lib/elastictastic/search.rb +180 -0
  30. data/lib/elastictastic/server_error.rb +15 -0
  31. data/lib/elastictastic/test_helpers.rb +172 -0
  32. data/lib/elastictastic/util.rb +63 -0
  33. data/lib/elastictastic/validations.rb +45 -0
  34. data/lib/elastictastic/version.rb +3 -0
  35. data/lib/elastictastic.rb +82 -0
  36. data/spec/environment.rb +6 -0
  37. data/spec/examples/active_model_lint_spec.rb +20 -0
  38. data/spec/examples/bulk_persistence_strategy_spec.rb +233 -0
  39. data/spec/examples/callbacks_spec.rb +96 -0
  40. data/spec/examples/dirty_spec.rb +238 -0
  41. data/spec/examples/document_spec.rb +600 -0
  42. data/spec/examples/mass_assignment_security_spec.rb +13 -0
  43. data/spec/examples/middleware_spec.rb +92 -0
  44. data/spec/examples/observing_spec.rb +141 -0
  45. data/spec/examples/parent_child_spec.rb +308 -0
  46. data/spec/examples/properties_spec.rb +92 -0
  47. data/spec/examples/scope_spec.rb +491 -0
  48. data/spec/examples/search_spec.rb +382 -0
  49. data/spec/examples/spec_helper.rb +15 -0
  50. data/spec/examples/validation_spec.rb +65 -0
  51. data/spec/models/author.rb +9 -0
  52. data/spec/models/blog.rb +5 -0
  53. data/spec/models/comment.rb +5 -0
  54. data/spec/models/post.rb +41 -0
  55. data/spec/models/post_observer.rb +11 -0
  56. data/spec/support/fakeweb_request_history.rb +13 -0
  57. metadata +227 -0
@@ -0,0 +1,67 @@
1
+ module Elastictastic
2
+ module Persistence
3
+ def save
4
+ persisted? ? update : create
5
+ end
6
+
7
+ def destroy
8
+ if persisted?
9
+ Elastictastic.persister.destroy(self)
10
+ else
11
+ raise OperationNotAllowed, "Cannot destroy transient document: #{inspect}"
12
+ end
13
+ end
14
+
15
+ def persisted?
16
+ !!@persisted
17
+ end
18
+
19
+ def transient?
20
+ !persisted?
21
+ end
22
+
23
+ def pending_save?
24
+ !!@pending_save
25
+ end
26
+
27
+ def pending_destroy?
28
+ !!@pending_destroy
29
+ end
30
+
31
+ def persisted!
32
+ @persisted = true
33
+ @pending_save = false
34
+ end
35
+
36
+ def transient!
37
+ @persisted = @pending_destroy = false
38
+ end
39
+
40
+ def pending_save!
41
+ @pending_save = true
42
+ end
43
+
44
+ def pending_destroy!
45
+ @pending_destroy = true
46
+ end
47
+
48
+ protected
49
+
50
+ def create
51
+ Elastictastic.persister.create(self)
52
+ end
53
+
54
+ def update
55
+ Elastictastic.persister.update(self)
56
+ end
57
+
58
+ private
59
+
60
+ def assert_transient!
61
+ if persisted?
62
+ raise IllegalModificationError,
63
+ "Cannot modify identity attribute after model has been saved."
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,236 @@
1
+ module Elastictastic
2
+ module Properties
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def each_field
7
+ properties.each_pair do |field, properties|
8
+ if properties['properties']
9
+ embeds[field].clazz.each_field do |embed_field, embed_properties|
10
+ yield("#{field}.#{embed_field}", embed_properties)
11
+ end
12
+ elsif properties['fields']
13
+ properties['fields'].each_pair do |variant_field, variant_properties|
14
+ if variant_field == field
15
+ yield(field, variant_properties)
16
+ else
17
+ yield("#{field}.#{variant_field}", variant_properties)
18
+ end
19
+ end
20
+ else
21
+ yield field, properties
22
+ end
23
+ end
24
+ end
25
+
26
+ def select_fields
27
+ [].tap do |fields|
28
+ each_field do |field, properties|
29
+ fields << [field, properties] if yield(field, properties)
30
+ end
31
+ end
32
+ end
33
+
34
+ def all_fields
35
+ @all_fields ||= {}.tap do |fields|
36
+ each_field { |field, properties| fields[field] = properties }
37
+ end
38
+ end
39
+
40
+ def field_properties
41
+ @field_properties ||= {}
42
+ end
43
+
44
+ def properties
45
+ return @properties if defined? @properties
46
+ @properties = {}
47
+ @properties.merge!(field_properties)
48
+ embeds.each_pair do |name, embed|
49
+ @properties[name] = { 'properties' => embed.clazz.properties }
50
+ end
51
+ @properties
52
+ end
53
+
54
+ def properties_for_field(field_name)
55
+ properties[field_name.to_s]
56
+ end
57
+
58
+ def embeds
59
+ @embeds ||= {}
60
+ end
61
+
62
+ def field(*field_names, &block)
63
+ options = field_names.extract_options!
64
+ field_names.each do |field_name|
65
+ define_field(field_name, options, &block)
66
+ end
67
+ end
68
+
69
+ def define_field(field_name, options, &block)
70
+ field_name = field_name.to_s
71
+
72
+ module_eval(<<-RUBY, __FILE__, __LINE__ + 1)
73
+ def #{field_name}
74
+ read_attribute(#{field_name.inspect})
75
+ end
76
+
77
+ def #{field_name}=(value)
78
+ write_attribute(#{field_name.inspect}, value)
79
+ end
80
+ RUBY
81
+
82
+ field_properties[field_name.to_s] =
83
+ Field.process(field_name, options, &block)
84
+ end
85
+
86
+ def embed(*embed_names)
87
+ options = embed_names.extract_options!
88
+
89
+ embed_names.each do |embed_name|
90
+ define_embed(embed_name, options)
91
+ end
92
+ end
93
+
94
+ def define_embed(embed_name, options)
95
+ embed_name = embed_name.to_s
96
+ embed = Association.new(embed_name, options)
97
+
98
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
99
+ def #{embed_name}
100
+ read_embed(#{embed_name.inspect})
101
+ end
102
+
103
+ def #{embed_name}=(value)
104
+ Util.call_or_each(value) do |check_value|
105
+ unless check_value.nil? || check_value.is_a?(#{embed.class_name})
106
+ raise TypeError, "Expected instance of class #{embed.class_name}; got \#{check_value.inspect}"
107
+ end
108
+ end
109
+ write_embed(#{embed_name.inspect}, value)
110
+ end
111
+ RUBY
112
+
113
+ embeds[embed_name] = embed
114
+ end
115
+ end
116
+
117
+ module InstanceMethods
118
+ def initialize(attributes = {})
119
+ super
120
+ @attributes = {}
121
+ @embeds = {}
122
+ self.attributes = attributes
123
+ end
124
+
125
+ def attributes
126
+ @attributes.with_indifferent_access
127
+ end
128
+
129
+ def attributes=(attributes)
130
+ attributes.each_pair do |field, value|
131
+ __send__(:"#{field}=", value)
132
+ end
133
+ end
134
+
135
+ def elasticsearch_doc
136
+ {}.tap do |doc|
137
+ @attributes.each_pair do |field, value|
138
+ next if value.nil?
139
+ doc[field] = Util.call_or_map(value) do |item|
140
+ serialize_value(field, item)
141
+ end
142
+ end
143
+ @embeds.each_pair do |field, embedded|
144
+ next if embedded.nil?
145
+ doc[field] = Util.call_or_map(embedded) do |item|
146
+ item.elasticsearch_doc
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ def elasticsearch_doc=(doc)
153
+ return if doc.nil?
154
+ doc.each_pair do |field_name, value|
155
+ if self.class.properties.has_key?(field_name)
156
+ embed = self.class.embeds[field_name]
157
+ if embed
158
+ embedded = Util.call_or_map(value) do |item|
159
+ embed.clazz.new.tap { |e| e.elasticsearch_doc = item }
160
+ end
161
+ write_embed(field_name, embedded)
162
+ else
163
+ deserialized = Util.call_or_map(value) do |item|
164
+ deserialize_value(field_name, item)
165
+ end
166
+ write_attribute(field_name, deserialized)
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ protected
173
+
174
+ def read_attribute(field)
175
+ @attributes[field.to_s]
176
+ end
177
+
178
+ def write_attribute(field, value)
179
+ if value.nil?
180
+ @attributes.delete(field.to_s)
181
+ else
182
+ @attributes[field.to_s] = value
183
+ end
184
+ end
185
+
186
+ def read_attributes
187
+ @attributes
188
+ end
189
+
190
+ def write_attributes(attributes)
191
+ @attributes = attributes
192
+ end
193
+
194
+ def read_embed(field)
195
+ @embeds[field.to_s]
196
+ end
197
+
198
+ def write_embed(field, value)
199
+ @embeds[field.to_s] = value
200
+ end
201
+
202
+ private
203
+
204
+ def serialize_value(field_name, value)
205
+ type = self.class.properties_for_field(field_name)['type'].to_s
206
+ case type
207
+ when 'date'
208
+ time = value.to_time
209
+ time.to_i * 1000 + time.usec / 1000
210
+ when 'integer', 'byte', 'short', 'long'
211
+ value.to_i
212
+ when 'float', 'double'
213
+ value.to_f
214
+ when 'boolean'
215
+ !!value
216
+ else
217
+ value
218
+ end
219
+ end
220
+
221
+ def deserialize_value(field_name, value)
222
+ return nil if value.nil?
223
+ if self.class.properties_for_field(field_name)['type'].to_s == 'date'
224
+ if value.is_a? Fixnum
225
+ sec, usec = value / 1000, (value % 1000) * 1000
226
+ Time.at(sec, usec).utc
227
+ else
228
+ Time.parse(value)
229
+ end
230
+ else
231
+ value
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,35 @@
1
+ module Elastictastic
2
+ class Railtie < Rails::Railtie
3
+ config.elastictastic = Elastictastic.config
4
+
5
+ initializer "elastictastic.configure_rails" do
6
+ config_path = Rails.root.join('config/elastictastic.yml').to_s
7
+ config = Elastictastic.config
8
+ app_name = Rails.application.class.name.split('::').first.underscore
9
+ config.default_index = "#{app_name}_#{Rails.env}"
10
+
11
+ if File.exist?(config_path)
12
+ yaml = YAML.load_file(config_path)[Rails.env]
13
+ if yaml
14
+ yaml.each_pair do |name, value|
15
+ config.__send__("#{name}=", value)
16
+ end
17
+ end
18
+ end
19
+
20
+ Elastictastic.config.logger = Rails.logger
21
+
22
+ require 'elastictastic/new_relic_instrumentation' if defined? NewRelic
23
+ end
24
+
25
+ initializer "elastictastic.instantiate_observers" do
26
+ config.after_initialize do
27
+ ::Elastictastic::Observing.instantiate_observers
28
+
29
+ ActionDispatch::Callbacks.to_prepare do
30
+ ::Elastictastic.instantiate_observers
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ warn 'Elastictastic::Resource is deprecated and will be removed in a future version. The new module name is Elastictastic::NestedDocument'
2
+ module Elastictastic
3
+ Resource = NestedDocument
4
+ end
@@ -0,0 +1,283 @@
1
+ require 'hashie'
2
+
3
+ module Elastictastic
4
+ class Scope < BasicObject
5
+ attr_reader :clazz, :index
6
+
7
+ def initialize(index, clazz, search = Search.new, parent_collection = nil)
8
+ @index, @clazz, @search, @parent_collection =
9
+ index, clazz, search, parent_collection
10
+ end
11
+
12
+ def initialize_instance(instance)
13
+ index = @index
14
+ instance.instance_eval { @index = index }
15
+ end
16
+
17
+ def params
18
+ @search.params
19
+ end
20
+
21
+ def each
22
+ if ::Kernel.block_given?
23
+ find_each { |result, hit| yield result }
24
+ else
25
+ ::Enumerator.new(self, :each)
26
+ end
27
+ end
28
+
29
+ def find_each(batch_options = {}, &block)
30
+ if block
31
+ find_in_batches(batch_options) { |batch| batch.each(&block) }
32
+ else
33
+ ::Enumerator.new(self, :find_each, batch_options)
34
+ end
35
+ end
36
+
37
+ def find_in_batches(batch_options = {}, &block)
38
+ return ::Enumerator.new(self, :find_in_batches, batch_options) unless block
39
+ if params.key?('size') || params.key?('from')
40
+ yield search_all
41
+ elsif params.key?('sort') || params.key('facets')
42
+ search_in_batches(&block)
43
+ else
44
+ scan_in_batches(batch_options, &block)
45
+ end
46
+ end
47
+
48
+ def count
49
+ return @count if defined? @count
50
+ populate_counts
51
+ @count
52
+ end
53
+
54
+ def empty?
55
+ count == 0
56
+ end
57
+
58
+ def any?(&block)
59
+ block ? each.any?(&block) : !empty?
60
+ end
61
+
62
+ def first
63
+ params = from(0).size(1).params
64
+ hit = ::Elastictastic.client.search(
65
+ @index,
66
+ @clazz.type,
67
+ params
68
+ )['hits']['hits'].first
69
+ materialize_hit(hit) if hit
70
+ end
71
+
72
+ def all
73
+ scoped({})
74
+ end
75
+
76
+ def all_facets
77
+ return @all_facets if defined? @all_facets
78
+ populate_counts
79
+ @all_facets ||= nil
80
+ end
81
+
82
+ def scoped(params, index = @index)
83
+ ::Elastictastic::Scope.new(
84
+ @index,
85
+ @clazz,
86
+ @search.merge(Search.new(params)),
87
+ @parent_collection
88
+ )
89
+ end
90
+
91
+ def destroy_all
92
+ #FIXME support delete-by-query
93
+ ::Elastictastic.client.delete(@index, @clazz.type)
94
+ end
95
+
96
+ def sync_mapping
97
+ #XXX is this a weird place to have this?
98
+ ::Elastictastic.client.put_mapping(index, type, @clazz.mapping)
99
+ end
100
+
101
+ def find(*ids)
102
+ #TODO support combining this with other filters/query
103
+ force_array = ::Array === ids.first
104
+ ids = ids.flatten
105
+ if ::Hash === ids.first
106
+ find_many_in_many_indices(*ids)
107
+ elsif ids.length == 1
108
+ instance = find_one(ids.first)
109
+ force_array ? [instance] : instance
110
+ else
111
+ find_many(ids)
112
+ end
113
+ end
114
+
115
+ Search::KEYS.each do |search_key|
116
+ module_eval <<-RUBY
117
+ def #{search_key}(*values, &block)
118
+ values << ScopeBuilder.build(&block) if block
119
+
120
+ case values.length
121
+ when 0 then ::Kernel.raise ::ArgumentError, "wrong number of arguments (0 for 1)"
122
+ when 1 then value = values.first
123
+ else value = values
124
+ end
125
+
126
+ scoped(#{search_key.inspect} => value)
127
+ end
128
+ RUBY
129
+ end
130
+
131
+ def method_missing(method, *args, &block)
132
+ if ::Enumerable.method_defined?(method)
133
+ each.__send__(method, *args, &block)
134
+ elsif @clazz.respond_to?(method)
135
+ @clazz.with_scope(self) do
136
+ @clazz.__send__(method, *args, &block)
137
+ end
138
+ else
139
+ super
140
+ end
141
+ end
142
+
143
+ def inspect
144
+ inspected = "#{@clazz.name}:#{@index.name}"
145
+ inspected << @search.params.to_json unless @search.params.empty?
146
+ inspected
147
+ end
148
+
149
+ protected
150
+
151
+ def search(search_params = {})
152
+ ::Elastictastic.client.search(
153
+ @index,
154
+ @clazz.type,
155
+ params,
156
+ search_params
157
+ )
158
+ end
159
+
160
+ private
161
+
162
+ def search_all
163
+ response = search(:search_type => 'query_then_fetch')
164
+ populate_counts(response)
165
+ materialize_hits(response['hits']['hits'])
166
+ end
167
+
168
+ def search_in_batches(&block)
169
+ from, size = 0, ::Elastictastic.config.default_batch_size
170
+ scope_with_size = self.size(size)
171
+ begin
172
+ scope = scope_with_size.from(from)
173
+ response = scope.search(:search_type => 'query_then_fetch')
174
+ populate_counts(response)
175
+ yield materialize_hits(response['hits']['hits'])
176
+ from += size
177
+ @count ||= scope.count
178
+ end while from < @count
179
+ end
180
+
181
+ def scan_in_batches(batch_options, &block)
182
+ batch_options = batch_options.symbolize_keys
183
+ scroll_options = {
184
+ :scroll => "#{batch_options[:ttl] || 60}s",
185
+ :size => batch_options[:batch_size] || ::Elastictastic.config.default_batch_size
186
+ }
187
+ scan_response = ::Elastictastic.client.search(
188
+ @index,
189
+ @clazz.type,
190
+ params,
191
+ scroll_options.merge(:search_type => 'scan')
192
+ )
193
+
194
+ @count = scan_response['hits']['total']
195
+ scroll_id = scan_response['_scroll_id']
196
+
197
+ begin
198
+ response = ::Elastictastic.client.scroll(scroll_id, scroll_options.slice(:scroll))
199
+ scroll_id = response['_scroll_id']
200
+ yield materialize_hits(response['hits']['hits'])
201
+ end until response['hits']['hits'].empty?
202
+ end
203
+
204
+ def populate_counts(response = nil)
205
+ response ||= search(:search_type => 'count')
206
+ @count ||= response['hits']['total']
207
+ if response['facets']
208
+ @all_facets ||= ::Hashie::Mash.new(response['facets'])
209
+ end
210
+ end
211
+
212
+ def find_one(id)
213
+ data = ::Elastictastic.client.get(index, type, id, params_for_find_one)
214
+ return nil if data['exists'] == false
215
+ case data['status']
216
+ when nil
217
+ materialize_hit(data)
218
+ when 404
219
+ nil
220
+ end
221
+ end
222
+
223
+ def find_many(ids)
224
+ docspec = ids.map do |id|
225
+ { '_id' => id }.merge!(params_for_find_many)
226
+ end
227
+ materialize_hits(
228
+ ::Elastictastic.client.mget(docspec, index, type)['docs']
229
+ ).map { |result, hit| result }
230
+ end
231
+
232
+ def find_many_in_many_indices(ids_by_index)
233
+ docs = []
234
+ ids_by_index.each_pair do |index, ids|
235
+ ::Kernel.Array(ids).each do |id|
236
+ docs << doc = {
237
+ '_id' => id.to_s,
238
+ '_type' => type,
239
+ '_index' => index
240
+ }
241
+ doc['fields'] = ::Kernel.Array(@search['fields']) if @search['fields']
242
+ end
243
+ end
244
+ materialize_hits(
245
+ ::Elastictastic.client.mget(docs)['docs']
246
+ ).map { |result, hit| result }
247
+ end
248
+
249
+ def params_for_find_one
250
+ params_for_find.tap do |params|
251
+ params['fields'] &&= params['fields'].join(',')
252
+ end
253
+ end
254
+
255
+ def params_for_find_many
256
+ params_for_find
257
+ end
258
+
259
+ def params_for_find
260
+ {}.tap do |params|
261
+ params['fields'] = ::Kernel.Array(@search['fields']) if @search['fields']
262
+ end
263
+ end
264
+
265
+ def materialize_hits(hits)
266
+ unless ::Kernel.block_given?
267
+ return ::Enumerator.new(self, :materialize_hits, hits)
268
+ end
269
+ hits.each do |hit|
270
+ unless hit['exists'] == false
271
+ yield materialize_hit(hit), ::Hashie::Mash.new(hit)
272
+ end
273
+ end
274
+ end
275
+
276
+ def materialize_hit(hit)
277
+ @clazz.new.tap do |result|
278
+ result.parent_collection = @parent_collection if @parent_collection
279
+ result.elasticsearch_hit = hit
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,32 @@
1
+ module Elastictastic
2
+ class ScopeBuilder < BasicObject
3
+ class <<self
4
+ private :new
5
+
6
+ def build(&block)
7
+ new(&block).build
8
+ end
9
+ end
10
+
11
+ def initialize(&block)
12
+ @block = block
13
+ end
14
+
15
+ def build
16
+ @scope = {}
17
+ instance_eval(&@block)
18
+ @scope
19
+ end
20
+
21
+ def method_missing(method, *args, &block)
22
+ args << ScopeBuilder.build(&block) if block
23
+ value =
24
+ case args.length
25
+ when 0 then {}
26
+ when 1 then args.first
27
+ else args
28
+ end
29
+ @scope[method.to_s] = value
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ module Elastictastic
2
+ module Scoped
3
+ def with_scope(scope)
4
+ scope_stack.push(scope)
5
+ begin
6
+ yield
7
+ ensure
8
+ scope_stack.pop
9
+ end
10
+ end
11
+
12
+ def scope_stack
13
+ Thread.current["#{name}::scope_stack"] ||= []
14
+ end
15
+
16
+ def current_scope
17
+ scope_stack.last || default_scope
18
+ end
19
+ end
20
+ end