elastictastic 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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