tire 0.4.3 → 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/.gitignore +1 -1
  2. data/.yardopts +1 -0
  3. data/README.markdown +2 -2
  4. data/examples/rails-application-template.rb +20 -6
  5. data/lib/tire.rb +2 -0
  6. data/lib/tire/alias.rb +1 -1
  7. data/lib/tire/configuration.rb +8 -0
  8. data/lib/tire/dsl.rb +69 -2
  9. data/lib/tire/index.rb +33 -20
  10. data/lib/tire/model/indexing.rb +7 -1
  11. data/lib/tire/model/persistence.rb +7 -4
  12. data/lib/tire/model/persistence/attributes.rb +1 -1
  13. data/lib/tire/model/persistence/finders.rb +4 -16
  14. data/lib/tire/model/search.rb +21 -8
  15. data/lib/tire/multi_search.rb +263 -0
  16. data/lib/tire/results/collection.rb +78 -49
  17. data/lib/tire/results/item.rb +6 -3
  18. data/lib/tire/results/pagination.rb +15 -1
  19. data/lib/tire/rubyext/ruby_1_8.rb +1 -7
  20. data/lib/tire/rubyext/uri_escape.rb +74 -0
  21. data/lib/tire/search.rb +33 -11
  22. data/lib/tire/search/facet.rb +8 -3
  23. data/lib/tire/search/filter.rb +1 -1
  24. data/lib/tire/search/highlight.rb +1 -1
  25. data/lib/tire/search/queries/match.rb +40 -0
  26. data/lib/tire/search/query.rb +42 -6
  27. data/lib/tire/search/scan.rb +1 -1
  28. data/lib/tire/search/script_field.rb +1 -1
  29. data/lib/tire/search/sort.rb +1 -1
  30. data/lib/tire/tasks.rb +17 -14
  31. data/lib/tire/version.rb +26 -8
  32. data/test/integration/active_record_searchable_test.rb +248 -129
  33. data/test/integration/boosting_queries_test.rb +32 -0
  34. data/test/integration/custom_score_queries_test.rb +1 -0
  35. data/test/integration/dsl_search_test.rb +9 -1
  36. data/test/integration/facets_test.rb +19 -6
  37. data/test/integration/match_query_test.rb +79 -0
  38. data/test/integration/multi_search_test.rb +114 -0
  39. data/test/integration/persistent_model_test.rb +58 -0
  40. data/test/models/article.rb +1 -1
  41. data/test/models/persistent_article_in_index.rb +16 -0
  42. data/test/models/persistent_article_with_defaults.rb +4 -3
  43. data/test/test_helper.rb +3 -1
  44. data/test/unit/configuration_test.rb +10 -0
  45. data/test/unit/index_test.rb +69 -27
  46. data/test/unit/model_initialization_test.rb +31 -0
  47. data/test/unit/model_persistence_test.rb +21 -7
  48. data/test/unit/model_search_test.rb +56 -5
  49. data/test/unit/multi_search_test.rb +304 -0
  50. data/test/unit/results_collection_test.rb +42 -2
  51. data/test/unit/results_item_test.rb +4 -0
  52. data/test/unit/search_facet_test.rb +35 -11
  53. data/test/unit/search_query_test.rb +96 -0
  54. data/test/unit/search_test.rb +60 -3
  55. data/test/unit/tire_test.rb +14 -0
  56. data/tire.gemspec +0 -1
  57. metadata +75 -44
@@ -0,0 +1,263 @@
1
+ module Tire
2
+ module Search
3
+
4
+ module Multi
5
+
6
+ # Wraps the search definitions for Tire::Multi::Search
7
+ #
8
+ class SearchDefinitions
9
+ include Enumerable
10
+
11
+ attr_reader :names
12
+
13
+ def initialize
14
+ @names = []
15
+ @searches = []
16
+ end
17
+
18
+ def << value
19
+ @names << value[:name]
20
+ @searches << value[:search]
21
+ end
22
+
23
+ def [] name
24
+ @searches[ @names.index(name) ]
25
+ end
26
+
27
+ def each(&block)
28
+ @searches.each(&block)
29
+ end
30
+
31
+ def size
32
+ @searches.size
33
+ end
34
+
35
+ def to_a
36
+ @searches
37
+ end
38
+ end
39
+
40
+ # Wraps the search result sets for Tire::Multi::Search
41
+ #
42
+ class Results
43
+ include Enumerable
44
+
45
+ def initialize(searches, results)
46
+ @searches = searches
47
+ @results = results
48
+ @collection = @results.zip(@searches.to_a).map do |results, search|
49
+ Tire::Results::Collection.new(results, search.options)
50
+ end
51
+ end
52
+
53
+ # Return a specific result sets
54
+ def [] name
55
+ if index = @searches.names.index(name)
56
+ @collection[ index ]
57
+ end
58
+ end
59
+
60
+ def each(&block)
61
+ @collection.each(&block)
62
+ end
63
+
64
+ def each_pair(&block)
65
+ @searches.names.zip(@collection).each(&block)
66
+ end
67
+
68
+ def size
69
+ @results.size
70
+ end
71
+
72
+ # Returns the multi-search result sets as a Hash with the search name
73
+ # as key and the results as value.
74
+ #
75
+ def to_hash
76
+ result = {}
77
+ each_pair { |name,results| result[name] = results }
78
+ result
79
+ end
80
+ end
81
+
82
+ # Build and perform a [multi-search](http://elasticsearch.org/guide/reference/api/multi-search.html)
83
+ # request.
84
+ #
85
+ # s = Tire::Search::Multi::Search.new 'my-index' do
86
+ # search :names do
87
+ # query { match :name, 'john' }
88
+ # end
89
+ # search :counts, search_type: 'count' do
90
+ # query { match :_all, 'john' }
91
+ # end
92
+ # search :other, index: 'other-index' do
93
+ # query { string "first_name:john" }
94
+ # end
95
+ # end
96
+ #
97
+ # You can optionally pass an index and type to the constructor, using them as defaults
98
+ # for searches which don't define them.
99
+ #
100
+ # Use the {#search} method to add a search definition to the request, passing it search options
101
+ # as a Hash and the search definition itself using Tire's DSL.
102
+ #
103
+ class Search
104
+
105
+ attr_reader :indices, :types, :path
106
+
107
+ def initialize(indices=nil, options={}, &block)
108
+ @indices = Array(indices)
109
+ @types = Array(options.delete(:type)).map { |type| Utils.escape(type) }
110
+ @options = options
111
+ @path = ['/', @indices.join(','), @types.join(','), '_msearch'].compact.join('/').squeeze('/')
112
+ @searches = Tire::Search::Multi::SearchDefinitions.new
113
+
114
+ block.arity < 1 ? instance_eval(&block) : block.call(self) if block_given?
115
+ end
116
+
117
+ # Add a search definition to the multi-search request.
118
+ #
119
+ # The usual search options such as `search_type`, `routing`, etc. can be passed as a Hash,
120
+ # and the search definition itself should be passed as a block using the Tire DSL.
121
+ #
122
+ def search(*args, &block)
123
+ name_or_options = args.pop
124
+
125
+ if name_or_options.is_a?(Hash)
126
+ options = name_or_options
127
+ name = args.pop
128
+ else
129
+ name = name_or_options
130
+ end
131
+
132
+ name ||= @searches.size
133
+ options ||= {}
134
+ indices = options.delete(:index) || options.delete(:indices)
135
+
136
+ @searches << { :name => name, :search => Tire::Search::Search.new(indices, options, &block) }
137
+ end
138
+
139
+ # Without argument, returns the collection of search definitions.
140
+ # With argument, returns a search definition by name or order.
141
+ #
142
+ def searches(name=nil)
143
+ name ? @searches[ name ] : @searches
144
+ end
145
+
146
+ # Returns and array of search definition names
147
+ #
148
+ def names
149
+ @searches.names
150
+ end
151
+
152
+ # Serializes the search definitions as an array of JSON definitions
153
+ #
154
+ def to_array
155
+ @searches.map do |search|
156
+ header = {}
157
+ header.update(:index => search.indices.join(',')) unless search.indices.empty?
158
+ header.update(:type => search.types.join(',')) unless search.types.empty?
159
+ header.update(:search_type => search.options[:search_type]) if search.options[:search_type]
160
+ header.update(:routing => search.options[:routing]) if search.options[:routing]
161
+ header.update(:preference => search.options[:preference]) if search.options[:preference]
162
+ body = search.to_hash
163
+ [ header, body ]
164
+ end.flatten
165
+ end
166
+
167
+ # Serializes the search definitions as a multi-line string payload
168
+ #
169
+ def to_payload
170
+ to_array.map { |line| MultiJson.encode(line) }.join("\n") + "\n"
171
+ end
172
+
173
+ # Returns the request URL
174
+ #
175
+ def url
176
+ [ Configuration.url, @path ].join
177
+ end
178
+
179
+ # Serializes the request URL parameters
180
+ #
181
+ def params
182
+ options = @options.dup
183
+ options.empty? ? '' : '?' + options.to_param
184
+ end
185
+
186
+ # Returns an enumerable collection of result sets.
187
+ #
188
+ # You can simply iterate over them:
189
+ #
190
+ # search.results.each do |results|
191
+ # puts results.each.map(&:name)
192
+ # end
193
+ #
194
+ # To iterate over named result sets, use the `each_pair` method:
195
+ #
196
+ # search.results.each_pair do |name,results|
197
+ # puts "Search #{name} got #{results.size} results"
198
+ # end
199
+ #
200
+ # To get a specific result set:
201
+ #
202
+ # search.results[:myname]
203
+ #
204
+ def results
205
+ @results || perform and @results
206
+ end
207
+
208
+ # Returns the HTTP response
209
+ #
210
+ def response
211
+ @response || perform and @response
212
+ end
213
+
214
+ # Returns the raw JSON as a Hash
215
+ #
216
+ def json
217
+ @json || perform and @json
218
+ end
219
+
220
+ def perform
221
+ @responses = Configuration.client.get(url + params, to_payload)
222
+ if @responses.failure?
223
+ STDERR.puts "[REQUEST FAILED] #{to_curl}\n"
224
+ raise SearchRequestFailed, @responses.to_s
225
+ end
226
+ @json = MultiJson.decode(@responses.body)
227
+ @results = Tire::Search::Multi::Results.new @searches, @json['responses']
228
+ return self
229
+ ensure
230
+ logged
231
+ end
232
+
233
+ def to_curl
234
+ %Q|curl -X GET '#{url}#{params.empty? ? '?' : params.to_s + '&'}pretty' -d '\n#{to_payload}'|
235
+ end
236
+
237
+ def logged(endpoint='_msearch')
238
+ if Configuration.logger
239
+
240
+ Configuration.logger.log_request endpoint, indices, to_curl
241
+
242
+ took = @json['took'] rescue nil
243
+ code = @response.code rescue nil
244
+
245
+ if Configuration.logger.level.to_s == 'debug'
246
+ body = if @json
247
+ MultiJson.encode( @json, :pretty => Configuration.pretty)
248
+ else
249
+ MultiJson.encode( MultiJson.load(@response.body), :pretty => Configuration.pretty) rescue ''
250
+ end
251
+ else
252
+ body = ''
253
+ end
254
+
255
+ Configuration.logger.log_response code || 'N/A', took || 'N/A', body || 'N/A'
256
+ end
257
+ end
258
+ end
259
+
260
+ end
261
+
262
+ end
263
+ end
@@ -5,62 +5,26 @@ module Tire
5
5
  include Enumerable
6
6
  include Pagination
7
7
 
8
- attr_reader :time, :total, :options, :facets
8
+ attr_reader :time, :total, :options, :facets, :max_score
9
9
 
10
10
  def initialize(response, options={})
11
- @response = response
12
- @options = options
13
- @time = response['took'].to_i
14
- @total = response['hits']['total'].to_i
15
- @facets = response['facets']
16
- @wrapper = options[:wrapper] || Configuration.wrapper
11
+ @response = response
12
+ @options = options
13
+ @time = response['took'].to_i
14
+ @total = response['hits']['total'].to_i rescue nil
15
+ @facets = response['facets']
16
+ @max_score = response['hits']['max_score'].to_f rescue nil
17
+ @wrapper = options[:wrapper] || Configuration.wrapper
17
18
  end
18
19
 
19
20
  def results
21
+ return [] if failure?
20
22
  @results ||= begin
21
23
  hits = @response['hits']['hits'].map { |d| d.update '_type' => Utils.unescape(d['_type']) }
22
-
23
24
  unless @options[:load]
24
- if @wrapper == Hash
25
- hits
26
- else
27
- hits.map do |h|
28
- document = {}
29
-
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']} )
33
-
34
- # Update the document with meta information
35
- ['_score', '_type', '_index', '_version', 'sort', 'highlight', '_explanation'].each { |key| document.update( {key => h[key]} || {} ) }
36
-
37
- # Return an instance of the "wrapper" class
38
- @wrapper.new(document)
39
- end
40
- end
41
-
25
+ __get_results_without_load(hits)
42
26
  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 } }
27
+ __get_results_with_load(hits)
64
28
  end
65
29
  end
66
30
  end
@@ -78,8 +42,21 @@ module Tire
78
42
  end
79
43
  alias :length :size
80
44
 
81
- def [](index)
82
- results[index]
45
+ def slice(*args)
46
+ results.slice(*args)
47
+ end
48
+ alias :[] :slice
49
+
50
+ def error
51
+ @response['error']
52
+ end
53
+
54
+ def success?
55
+ error.to_s.empty?
56
+ end
57
+
58
+ def failure?
59
+ ! success?
83
60
  end
84
61
 
85
62
  def to_ary
@@ -108,6 +85,58 @@ module Tire
108
85
  fields
109
86
  end
110
87
 
88
+ def __get_results_without_load(hits)
89
+ if @wrapper == Hash
90
+ hits
91
+ else
92
+ hits.map do |h|
93
+ document = {}
94
+
95
+ # Update the document with content and ID
96
+ document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
97
+ document.update( {'id' => h['_id']} )
98
+
99
+ # Update the document with meta information
100
+ ['_score', '_type', '_index', '_version', 'sort', 'highlight', '_explanation'].each { |key| document.update( {key => h[key]} || {} ) }
101
+
102
+ # Return an instance of the "wrapper" class
103
+ @wrapper.new(document)
104
+ end
105
+ end
106
+ end
107
+
108
+ def __get_results_with_load(hits)
109
+ return [] if hits.empty?
110
+
111
+ records = {}
112
+ @response['hits']['hits'].group_by { |item| item['_type'] }.each do |type, items|
113
+ raise NoMethodError, "You have tried to eager load the model instances, " +
114
+ "but Tire cannot find the model class because " +
115
+ "document has no _type property." unless type
116
+
117
+ begin
118
+ klass = type.camelize.constantize
119
+ rescue NameError => e
120
+ raise NameError, "You have tried to eager load the model instances, but " +
121
+ "Tire cannot find the model class '#{type.camelize}' " +
122
+ "based on _type '#{type}'.", e.backtrace
123
+ end
124
+
125
+ records[type] = __find_records_by_ids klass, items.map { |h| h['_id'] }
126
+ end
127
+
128
+ # Reorder records to preserve the order from search results
129
+ @response['hits']['hits'].map do |item|
130
+ records[item['_type']].detect do |record|
131
+ record.id.to_s == item['_id'].to_s
132
+ end
133
+ end
134
+ end
135
+
136
+ def __find_records_by_ids(klass, ids)
137
+ @options[:load] === true ? klass.find(ids) : klass.find(ids, @options[:load])
138
+ end
139
+
111
140
  end
112
141
 
113
142
  end
@@ -20,11 +20,14 @@ module Tire
20
20
  end
21
21
  end
22
22
 
23
- # Delegate method to a key in underlying hash, if present,
24
- # otherwise return +nil+.
23
+ # Delegate method to a key in underlying hash, if present, otherwise return +nil+.
25
24
  #
26
25
  def method_missing(method_name, *arguments)
27
- @attributes.has_key?(method_name.to_sym) ? @attributes[method_name.to_sym] : nil
26
+ @attributes[method_name.to_sym]
27
+ end
28
+
29
+ def respond_to?(method_name)
30
+ @attributes.has_key?(method_name.to_sym) || super
28
31
  end
29
32
 
30
33
  def [](key)
@@ -5,12 +5,17 @@ module Tire
5
5
  #
6
6
  module Pagination
7
7
 
8
+ def default_per_page
9
+ 10
10
+ end
11
+ module_function :default_per_page
12
+
8
13
  def total_entries
9
14
  @total
10
15
  end
11
16
 
12
17
  def per_page
13
- (@options[:per_page] || @options[:size] || 10 ).to_i
18
+ (@options[:per_page] || @options[:size] || default_per_page ).to_i
14
19
  end
15
20
 
16
21
  def total_pages
@@ -48,6 +53,15 @@ module Tire
48
53
  alias :num_pages :total_pages
49
54
  alias :offset_value :offset
50
55
 
56
+ def first_page?
57
+ current_page == 1
58
+ end
59
+
60
+ def last_page?
61
+ current_page == total_pages
62
+ end
63
+
64
+
51
65
  end
52
66
 
53
67
  end