ssickles-tire 0.4.2.7 → 0.4.3

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 (59) hide show
  1. data/lib/tire.rb +18 -3
  2. data/lib/tire/alias.rb +11 -35
  3. data/lib/tire/index.rb +34 -76
  4. data/lib/tire/model/callbacks.rb +40 -0
  5. data/lib/tire/model/import.rb +26 -0
  6. data/lib/tire/model/indexing.rb +128 -0
  7. data/lib/tire/model/naming.rb +100 -0
  8. data/lib/tire/model/percolate.rb +99 -0
  9. data/lib/tire/model/persistence.rb +72 -0
  10. data/lib/tire/model/persistence/attributes.rb +143 -0
  11. data/lib/tire/model/persistence/finders.rb +66 -0
  12. data/lib/tire/model/persistence/storage.rb +71 -0
  13. data/lib/tire/model/search.rb +305 -0
  14. data/lib/tire/results/collection.rb +38 -13
  15. data/lib/tire/results/item.rb +19 -0
  16. data/lib/tire/rubyext/hash.rb +8 -0
  17. data/lib/tire/rubyext/ruby_1_8.rb +54 -0
  18. data/lib/tire/rubyext/symbol.rb +11 -0
  19. data/lib/tire/search.rb +7 -8
  20. data/lib/tire/search/scan.rb +8 -8
  21. data/lib/tire/search/sort.rb +1 -1
  22. data/lib/tire/utils.rb +17 -0
  23. data/lib/tire/version.rb +7 -38
  24. data/test/integration/active_model_indexing_test.rb +51 -0
  25. data/test/integration/active_model_searchable_test.rb +114 -0
  26. data/test/integration/active_record_searchable_test.rb +446 -0
  27. data/test/integration/mongoid_searchable_test.rb +309 -0
  28. data/test/integration/persistent_model_test.rb +117 -0
  29. data/test/integration/reindex_test.rb +2 -2
  30. data/test/integration/scan_test.rb +1 -1
  31. data/test/models/active_model_article.rb +31 -0
  32. data/test/models/active_model_article_with_callbacks.rb +49 -0
  33. data/test/models/active_model_article_with_custom_document_type.rb +7 -0
  34. data/test/models/active_model_article_with_custom_index_name.rb +7 -0
  35. data/test/models/active_record_models.rb +122 -0
  36. data/test/models/mongoid_models.rb +97 -0
  37. data/test/models/persistent_article.rb +11 -0
  38. data/test/models/persistent_article_in_namespace.rb +12 -0
  39. data/test/models/persistent_article_with_casting.rb +28 -0
  40. data/test/models/persistent_article_with_defaults.rb +11 -0
  41. data/test/models/persistent_articles_with_custom_index_name.rb +10 -0
  42. data/test/models/supermodel_article.rb +17 -0
  43. data/test/models/validated_model.rb +11 -0
  44. data/test/test_helper.rb +27 -3
  45. data/test/unit/active_model_lint_test.rb +17 -0
  46. data/test/unit/index_alias_test.rb +3 -17
  47. data/test/unit/index_test.rb +30 -18
  48. data/test/unit/model_callbacks_test.rb +116 -0
  49. data/test/unit/model_import_test.rb +71 -0
  50. data/test/unit/model_persistence_test.rb +516 -0
  51. data/test/unit/model_search_test.rb +899 -0
  52. data/test/unit/results_collection_test.rb +60 -0
  53. data/test/unit/results_item_test.rb +37 -0
  54. data/test/unit/rubyext_test.rb +3 -3
  55. data/test/unit/search_test.rb +1 -6
  56. data/test/unit/tire_test.rb +15 -0
  57. data/tire.gemspec +30 -13
  58. metadata +153 -41
  59. data/lib/tire/rubyext/to_json.rb +0 -21
@@ -0,0 +1,71 @@
1
+ module Tire
2
+ module Model
3
+
4
+ module Persistence
5
+
6
+ # Provides infrastructure for storing records in _ElasticSearch_.
7
+ #
8
+ module Storage
9
+
10
+ def self.included(base)
11
+
12
+ base.class_eval do
13
+ extend ClassMethods
14
+ include InstanceMethods
15
+ end
16
+
17
+ end
18
+
19
+ module ClassMethods
20
+
21
+ def create(args={})
22
+ document = new(args)
23
+ return false unless document.valid?
24
+ document.save
25
+ document
26
+ end
27
+
28
+ end
29
+
30
+ module InstanceMethods
31
+
32
+ def update_attribute(name, value)
33
+ __update_attributes name => value
34
+ save
35
+ end
36
+
37
+ def update_attributes(attributes={})
38
+ __update_attributes attributes
39
+ save
40
+ end
41
+
42
+ def save
43
+ return false unless valid?
44
+ run_callbacks :save do
45
+ # Document#id is set in the +update_elasticsearch_index+ method,
46
+ # where we have access to the JSON response
47
+ end
48
+ self
49
+ end
50
+
51
+ def destroy
52
+ run_callbacks :destroy do
53
+ @destroyed = true
54
+ end
55
+ self.freeze
56
+ end
57
+
58
+ # TODO: Implement `new_record?` and clean up
59
+
60
+ def destroyed?; !!@destroyed; end
61
+
62
+ def persisted?; !!id; end
63
+
64
+ end
65
+
66
+ end
67
+
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,305 @@
1
+ module Tire
2
+ module Model
3
+
4
+ # Main module containing the search infrastructure for ActiveModel classes.
5
+ #
6
+ # By including this module, you'll provide the model with facilities to
7
+ # perform searches against index, define index settings and mappings,
8
+ # access the index object, etc.
9
+ #
10
+ # All the _Tire_ methods are accessible via the "proxy" class and instance
11
+ # methods of the model, named `tire`, eg. `Article.tire.search 'foo'`.
12
+ #
13
+ # When there's no clash with a method in the class (your own, defined by another gem, etc)
14
+ # _Tire_ will bring these methods to the top-level namespace of the class,
15
+ # eg. `Article.search 'foo'`.
16
+ #
17
+ # You'll find the relevant methods in the ClassMethods and InstanceMethods module.
18
+ #
19
+ #
20
+ module Search
21
+
22
+ # Alias for Tire::Model::Naming::ClassMethods.index_prefix
23
+ #
24
+ def self.index_prefix(*args)
25
+ Naming::ClassMethods.index_prefix(*args)
26
+ end
27
+
28
+ module ClassMethods
29
+
30
+ # Returns search results for a given query.
31
+ #
32
+ # Query can be passed simply as a String:
33
+ #
34
+ # Article.search 'love'
35
+ #
36
+ # Any options, such as pagination or sorting, can be passed as a second argument:
37
+ #
38
+ # Article.search 'love', :per_page => 25, :page => 2
39
+ # Article.search 'love', :sort => 'title'
40
+ #
41
+ # For more powerful query definition, use the query DSL passed as a block:
42
+ #
43
+ # Article.search do
44
+ # query { terms :tags, ['ruby', 'python'] }
45
+ # facet 'tags' { terms :tags }
46
+ # end
47
+ #
48
+ # You can pass options as the first argument, in this case:
49
+ #
50
+ # Article.search :per_page => 25, :page => 2 do
51
+ # query { string 'love' }
52
+ # end
53
+ #
54
+ # This methods returns a Tire::Results::Collection instance, containing instances
55
+ # of Tire::Results::Item, populated by the data available in _ElasticSearch, by default.
56
+ #
57
+ # If you'd like to load the "real" models from the database, you may use the `:load` option:
58
+ #
59
+ # Article.search 'love', :load => true
60
+ #
61
+ # You can pass options as a Hash to the model's `find` method:
62
+ #
63
+ # Article.search :load => { :include => 'comments' } do ... end
64
+ #
65
+ def search(*args, &block)
66
+ default_options = {:type => document_type, :index => index.name}
67
+
68
+ if block_given?
69
+ options = args.shift || {}
70
+ else
71
+ query, options = args
72
+ options ||= {}
73
+ end
74
+
75
+ sort = Array( options[:order] || options[:sort] )
76
+ options = default_options.update(options)
77
+
78
+ s = Tire::Search::Search.new(options.delete(:index), options)
79
+ s.size( options[:per_page].to_i ) if options[:per_page]
80
+ s.from( options[:page].to_i <= 1 ? 0 : (options[:per_page].to_i * (options[:page].to_i-1)) ) if options[:page] && options[:per_page]
81
+ s.sort do
82
+ sort.each do |t|
83
+ field_name, direction = t.split(' ')
84
+ by field_name, direction
85
+ end
86
+ end unless sort.empty?
87
+
88
+ if block_given?
89
+ block.arity < 1 ? s.instance_eval(&block) : block.call(s)
90
+ else
91
+ s.query { string query }
92
+ # TODO: Actualy, allow passing all the valid options from
93
+ # <http://www.elasticsearch.org/guide/reference/api/search/uri-request.html>
94
+ s.fields Array(options[:fields]) if options[:fields]
95
+ end
96
+
97
+ s.results
98
+ end
99
+
100
+ # Returns a Tire::Index instance for this model.
101
+ #
102
+ # Example usage: `Article.index.refresh`.
103
+ #
104
+ def index
105
+ name = index_name.respond_to?(:to_proc) ? klass.instance_eval(&index_name) : index_name
106
+ @index = Index.new(name)
107
+ end
108
+
109
+ end
110
+
111
+ module InstanceMethods
112
+
113
+ # Returns a Tire::Index instance for this instance of the model.
114
+ #
115
+ # Example usage: `@article.index.refresh`.
116
+ #
117
+ def index
118
+ instance.class.tire.index
119
+ end
120
+
121
+ # Updates the index in _ElasticSearch_.
122
+ #
123
+ # On model instance create or update, it will store its serialized representation in the index.
124
+ #
125
+ # On model destroy, it will remove the corresponding document from the index.
126
+ #
127
+ # It will also execute any `<after|before>_update_elasticsearch_index` callback hooks.
128
+ #
129
+ def update_index
130
+ #instance.send :_run_update_elasticsearch_index_callbacks do
131
+ if instance.destroyed?
132
+ index.remove instance
133
+ else
134
+ response = index.store( instance, {:percolate => percolator} )
135
+ instance.id ||= response['_id'] if instance.respond_to?(:id=)
136
+ instance._index = response['_index'] if instance.respond_to?(:_index=)
137
+ instance._type = response['_type'] if instance.respond_to?(:_type=)
138
+ instance._version = response['_version'] if instance.respond_to?(:_version=)
139
+ instance.matches = response['matches'] if instance.respond_to?(:matches=)
140
+ self
141
+ end
142
+ #end
143
+ end
144
+ alias :update_elasticsearch_index :update_index
145
+ alias :update_elastic_search_index :update_index
146
+
147
+ # The default JSON serialization of the model, based on its `#to_hash` representation.
148
+ #
149
+ # If you don't define any mapping, the model is serialized as-is.
150
+ #
151
+ # If you do define the mapping for _ElasticSearch_, only attributes
152
+ # declared in the mapping are serialized.
153
+ #
154
+ # For properties declared with the `:as` option, the passed String or Proc
155
+ # is evaluated in the instance context.
156
+ #
157
+ def to_indexed_json
158
+ if instance.class.tire.mapping.empty?
159
+ # Reject the id and type keys
160
+ instance.to_hash.reject {|key,_| key.to_s == 'id' || key.to_s == 'type' }.to_json
161
+ else
162
+ mapping = instance.class.tire.mapping
163
+ # Reject keys not declared in mapping
164
+ hash = instance.to_hash.reject { |key, value| ! mapping.keys.map(&:to_s).include?(key.to_s) }
165
+
166
+ # Evalute the `:as` options
167
+ mapping.each do |key, options|
168
+ case options[:as]
169
+ when String
170
+ hash[key] = instance.instance_eval(options[:as])
171
+ when Proc
172
+ hash[key] = instance.instance_eval(&options[:as])
173
+ end
174
+ end
175
+
176
+ hash.to_json
177
+ end
178
+ end
179
+
180
+ def matches
181
+ @attributes['matches']
182
+ end
183
+
184
+ def matches=(value)
185
+ @attributes ||= {}; @attributes['matches'] = value
186
+ end
187
+
188
+ end
189
+
190
+ module Loader
191
+
192
+ # Load the "real" model from the database via the corresponding model's `find` method.
193
+ #
194
+ # Notice that there's an option to eagerly load models with the `:load` option
195
+ # for the search method.
196
+ #
197
+ def load(options=nil)
198
+ options ? self.class.find(self.id, options) : self.class.find(self.id)
199
+ end
200
+
201
+ end
202
+
203
+ # An object containing _Tire's_ model class methods, accessed as `Article.tire`.
204
+ #
205
+ class ClassMethodsProxy
206
+ include Tire::Model::Naming::ClassMethods
207
+ include Tire::Model::Import::ClassMethods
208
+ include Tire::Model::Indexing::ClassMethods
209
+ include Tire::Model::Percolate::ClassMethods
210
+ include ClassMethods
211
+
212
+ INTERFACE = public_instance_methods.map(&:to_sym) - Object.public_instance_methods.map(&:to_sym)
213
+
214
+ attr_reader :klass
215
+ def initialize(klass)
216
+ @klass = klass
217
+ end
218
+
219
+ end
220
+
221
+ # An object containing _Tire's_ model instance methods, accessed as `@article.tire`.
222
+ #
223
+ class InstanceMethodsProxy
224
+ include Tire::Model::Naming::InstanceMethods
225
+ include Tire::Model::Percolate::InstanceMethods
226
+ include InstanceMethods
227
+
228
+ INTERFACE = public_instance_methods.map(&:to_sym) - Object.public_instance_methods.map(&:to_sym)
229
+
230
+ attr_reader :instance
231
+ def initialize(instance)
232
+ @instance = instance
233
+ end
234
+ end
235
+
236
+ # A hook triggered by the `include Tire::Model::Search` statement in the model.
237
+ #
238
+ def self.included(base)
239
+ base.class_eval do
240
+
241
+ # Returns proxy to the _Tire's_ class methods.
242
+ #
243
+ def self.tire &block
244
+ @__tire__ ||= ClassMethodsProxy.new(self)
245
+
246
+ @__tire__.instance_eval(&block) if block_given?
247
+ @__tire__
248
+ end
249
+
250
+ # Returns proxy to the _Tire's_ instance methods.
251
+ #
252
+ def tire &block
253
+ @__tire__ ||= InstanceMethodsProxy.new(self)
254
+
255
+ @__tire__.instance_eval(&block) if block_given?
256
+ @__tire__
257
+ end
258
+
259
+ # Define _Tire's_ callbacks (<after|before>_update_elasticsearch_index).
260
+ #
261
+ #define_model_callbacks(:update_elasticsearch_index, :only => [:after, :before]) if \
262
+ # respond_to?(:define_model_callbacks)
263
+
264
+ # Serialize the model as a Hash.
265
+ #
266
+ # Uses `serializable_hash` representation of the model,
267
+ # unless implemented in the model already.
268
+ #
269
+ def to_hash
270
+ self.serializable_hash
271
+ end unless instance_methods.map(&:to_sym).include?(:to_hash)
272
+
273
+ end
274
+
275
+ # Alias _Tire's_ class methods in the top-level namespace of the model,
276
+ # unless there's a conflict with existing method.
277
+ #
278
+ ClassMethodsProxy::INTERFACE.each do |method|
279
+ base.class_eval <<-"end;", __FILE__, __LINE__ unless base.public_methods.map(&:to_sym).include?(method.to_sym)
280
+ def self.#{method}(*args, &block) # def search(*args, &block)
281
+ tire.__send__(#{method.inspect}, *args, &block) # tire.__send__(:search, *args, &block)
282
+ end # end
283
+ end;
284
+ end
285
+
286
+ # Alias _Tire's_ instance methods in the top-level namespace of the model,
287
+ # unless there's a conflict with existing method
288
+ InstanceMethodsProxy::INTERFACE.each do |method|
289
+ base.class_eval <<-"end;", __FILE__, __LINE__ unless base.instance_methods.map(&:to_sym).include?(method.to_sym)
290
+ def #{method}(*args, &block) # def to_indexed_json(*args, &block)
291
+ tire.__send__(#{method.inspect}, *args, &block) # tire.__send__(:to_indexed_json, *args, &block)
292
+ end # end
293
+ end;
294
+ end
295
+
296
+ # Include the `load` functionality in Results::Item
297
+ #
298
+ Results::Item.send :include, Loader
299
+ end
300
+
301
+
302
+ end
303
+
304
+ end
305
+ end
@@ -18,24 +18,49 @@ module Tire
18
18
 
19
19
  def results
20
20
  @results ||= begin
21
- hits = @response['hits']['hits'].map { |d| d.update '_type' => EscapeUtils.unescape_url(d['_type']) }
21
+ hits = @response['hits']['hits'].map { |d| d.update '_type' => Utils.unescape(d['_type']) }
22
22
 
23
- if @wrapper == Hash
24
- hits
25
- else
26
- hits.map do |h|
27
- document = {}
23
+ unless @options[:load]
24
+ if @wrapper == Hash
25
+ hits
26
+ else
27
+ hits.map do |h|
28
+ document = {}
28
29
 
29
- # Update the document with content and ID
30
- document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
31
- document.update( {'id' => h['_id']} )
30
+ # Update the document with content and ID
31
+ document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
32
+ document.update( {'id' => h['_id']} )
32
33
 
33
- # Update the document with meta information
34
- ['_score', '_type', '_index', '_version', 'sort', 'highlight', '_explanation'].each { |key| document.update( {key => h[key]} || {} ) }
34
+ # Update the document with meta information
35
+ ['_score', '_type', '_index', '_version', 'sort', 'highlight', '_explanation'].each { |key| document.update( {key => h[key]} || {} ) }
35
36
 
36
- # Return an instance of the "wrapper" class
37
- @wrapper.new(document)
37
+ # Return an instance of the "wrapper" class
38
+ @wrapper.new(document)
39
+ end
38
40
  end
41
+
42
+ else
43
+ return [] if hits.empty?
44
+
45
+ records = {}
46
+ @response['hits']['hits'].group_by { |item| item['_type'] }.each do |type, items|
47
+ raise NoMethodError, "You have tried to eager load the model instances, " +
48
+ "but Tire cannot find the model class because " +
49
+ "document has no _type property." unless type
50
+
51
+ begin
52
+ klass = type.camelize.constantize
53
+ rescue NameError => e
54
+ raise NameError, "You have tried to eager load the model instances, but " +
55
+ "Tire cannot find the model class '#{type.camelize}' " +
56
+ "based on _type '#{type}'.", e.backtrace
57
+ end
58
+ ids = items.map { |h| h['_id'] }
59
+ records[type] = @options[:load] === true ? klass.find(ids) : klass.find(ids, @options[:load])
60
+ end
61
+
62
+ # Reorder records to preserve order from search results
63
+ @response['hits']['hits'].map { |item| records[item['_type']].detect { |record| record.id.to_s == item['_id'].to_s } }
39
64
  end
40
65
  end
41
66
  end
@@ -2,6 +2,9 @@ module Tire
2
2
  module Results
3
3
 
4
4
  class Item
5
+ #extend ActiveModel::Naming
6
+ #include ActiveModel::Conversion
7
+
5
8
  # Create new instance, recursively converting all Hashes to Item
6
9
  # and leaving everything else alone.
7
10
  #
@@ -40,6 +43,14 @@ module Tire
40
43
  !!id
41
44
  end
42
45
 
46
+ def errors
47
+ ActiveModel::Errors.new(self)
48
+ end
49
+
50
+ def valid?
51
+ true
52
+ end
53
+
43
54
  def to_key
44
55
  persisted? ? [id] : nil
45
56
  end
@@ -48,6 +59,14 @@ module Tire
48
59
  @attributes
49
60
  end
50
61
 
62
+ # Let's pretend we're someone else in Rails
63
+ #
64
+ def class
65
+ defined?(::Rails) && @attributes[:_type] ? @attributes[:_type].camelize.constantize : super
66
+ rescue NameError
67
+ super
68
+ end
69
+
51
70
  def inspect
52
71
  s = []; @attributes.each { |k,v| s << "#{k}: #{v.inspect}" }
53
72
  %Q|<Item#{self.class.to_s == 'Tire::Results::Item' ? '' : " (#{self.class})"} #{s.join(', ')}>|