tire 0.1.16 → 0.2.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.
@@ -9,7 +9,7 @@ module Tire
9
9
  base.send :after_destroy, :update_elastic_search_index
10
10
  end
11
11
 
12
- if base.respond_to?(:before_destroy) && !base.instance_methods.include?('destroyed?')
12
+ if base.respond_to?(:before_destroy) && !base.instance_methods.map(&:to_sym).include?(:destroyed?)
13
13
  base.class_eval do
14
14
  before_destroy { @destroyed = true }
15
15
  def destroyed?; !!@destroyed; end
@@ -28,36 +28,67 @@ module Tire
28
28
  self.serializable_hash
29
29
  end unless instance_methods.map(&:to_sym).include?(:to_hash)
30
30
  end
31
+
32
+ Results::Item.send :include, Loader
31
33
  end
32
34
 
33
35
  module ClassMethods
34
36
 
35
- def search(query=nil, options={}, &block)
36
- old_wrapper = Tire::Configuration.wrapper
37
- Tire::Configuration.wrapper self
38
-
39
- sort = Array( options[:order] || options[:sort] )
40
- options = {:type => document_type}.update(options)
37
+ # Returns search results for a given query.
38
+ #
39
+ # Query can be passed simply as a String:
40
+ #
41
+ # Article.search 'love'
42
+ #
43
+ # Any options, such as pagination or sorting, can be passed as a second argument:
44
+ #
45
+ # Article.search 'love', :per_page => 25, :page => 2
46
+ # Article.search 'love', :sort => 'title'
47
+ #
48
+ # For more powerful query definition, use the query DSL passed as a block:
49
+ #
50
+ # Article.search do
51
+ # query { terms :tags, ['ruby', 'python'] }
52
+ # facet 'tags' { terms :tags }
53
+ # end
54
+ #
55
+ # You can pass options as the first argument, in this case:
56
+ #
57
+ # Article.search :per_page => 25, :page => 2 do
58
+ # query { string 'love' }
59
+ # end
60
+ #
61
+ #
62
+ def search(*args, &block)
63
+ default_options = {:type => document_type}
41
64
 
42
- unless block_given?
43
- s = Tire::Search::Search.new(elasticsearch_index.name, options)
44
- s.query { string query }
45
- s.sort do
46
- sort.each do |t|
47
- field_name, direction = t.split(' ')
48
- by field_name, direction
49
- end
50
- end unless sort.empty?
51
- s.size( options[:per_page].to_i ) if options[:per_page]
52
- s.from( options[:page].to_i <= 1 ? 0 : (options[:per_page].to_i * (options[:page].to_i-1)) ) if options[:page] && options[:per_page]
53
- s.perform.results
65
+ if block_given?
66
+ options = args.shift || {}
54
67
  else
55
- s = Tire::Search::Search.new(elasticsearch_index.name, options)
68
+ query, options = args
69
+ options ||= {}
70
+ end
71
+
72
+ sort = Array( options[:order] || options[:sort] )
73
+ options = default_options.update(options)
74
+
75
+ s = Tire::Search::Search.new(elasticsearch_index.name, options)
76
+ s.size( options[:per_page].to_i ) if options[:per_page]
77
+ s.from( options[:page].to_i <= 1 ? 0 : (options[:per_page].to_i * (options[:page].to_i-1)) ) if options[:page] && options[:per_page]
78
+ s.sort do
79
+ sort.each do |t|
80
+ field_name, direction = t.split(' ')
81
+ by field_name, direction
82
+ end
83
+ end unless sort.empty?
84
+
85
+ if block_given?
56
86
  block.arity < 1 ? s.instance_eval(&block) : block.call(s)
57
- s.perform.results
87
+ else
88
+ s.query { string query }
58
89
  end
59
- ensure
60
- Tire::Configuration.wrapper old_wrapper
90
+
91
+ s.perform.results
61
92
  end
62
93
 
63
94
  # Wrapper for the ES index for this class
@@ -76,11 +107,6 @@ module Tire
76
107
 
77
108
  module InstanceMethods
78
109
 
79
- def score
80
- Tire.warn "#{self.class}#score has been deprecated, please use #{self.class}#_score instead."
81
- attributes['_score']
82
- end
83
-
84
110
  def index
85
111
  self.class.elasticsearch_index
86
112
  end
@@ -114,6 +140,16 @@ module Tire
114
140
 
115
141
  end
116
142
 
143
+ module Loader
144
+
145
+ # Load the "real" model from the database via the corresponding model's `find` method
146
+ #
147
+ def load(options=nil)
148
+ options ? self.class.find(self.id, options) : self.class.find(self.id)
149
+ end
150
+
151
+ end
152
+
117
153
  extend ClassMethods
118
154
  end
119
155
 
@@ -18,26 +18,44 @@ module Tire
18
18
 
19
19
  def results
20
20
  @results ||= begin
21
- @response['hits']['hits'].map do |h|
22
- if @wrapper == Hash then h
23
- else
24
- document = {}
21
+ unless @options[:load]
22
+ @response['hits']['hits'].map do |h|
23
+ if @wrapper == Hash then h
24
+ else
25
+ document = {}
25
26
 
26
- # Update the document with content and ID
27
- document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
28
- document.update( {'id' => h['_id']} )
27
+ # Update the document with content and ID
28
+ document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
29
+ document.update( {'id' => h['_id']} )
29
30
 
30
- # Update the document with meta information
31
- ['_score', '_type', '_index', '_version', 'sort', 'highlight'].each { |key| document.update( {key => h[key]} || {} ) }
31
+ # Update the document with meta information
32
+ ['_score', '_type', '_index', '_version', 'sort', 'highlight'].each { |key| document.update( {key => h[key]} || {} ) }
32
33
 
33
- # for instantiating ActiveRecord with arbitrary attributes and setting @new_record etc.
34
- if @wrapper.respond_to?(:instantiate, true)
35
- @wrapper.send(:instantiate, document)
36
- else
34
+ # Return an instance of the "wrapper" class
37
35
  @wrapper.new(document)
38
36
  end
39
- end
40
- end
37
+ end
38
+ else
39
+ begin
40
+ return [] if @response['hits']['total'] == 0
41
+
42
+ type = @response['hits']['hits'].first['_type']
43
+ raise NoMethodError, "You have tried to eager load the model instances, " +
44
+ "but Tire cannot find the model class because " +
45
+ "document has no _type property." unless type
46
+
47
+ klass = type.camelize.constantize
48
+ ids = @response['hits']['hits'].map { |h| h['_id'] }
49
+ records = @options[:load] === true ? klass.find(ids) : klass.find(ids, @options[:load])
50
+
51
+ # Reorder records to preserve order from search results
52
+ ids.map { |id| records.detect { |record| record.id.to_s == id.to_s } }
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
+ end
41
59
  end
42
60
  end
43
61
 
@@ -1,15 +1,22 @@
1
1
  module Tire
2
2
  module Results
3
3
 
4
- class Item < Hash
4
+ class Item
5
+ extend ActiveModel::Naming
6
+ include ActiveModel::Conversion
5
7
 
6
8
  # Create new instance, recursively converting all Hashes to Item
7
9
  # and leaving everything else alone.
8
10
  #
9
11
  def initialize(args={})
10
12
  raise ArgumentError, "Please pass a Hash-like object" unless args.respond_to?(:each_pair)
13
+ @attributes = {}
11
14
  args.each_pair do |key, value|
12
- self[key.to_sym] = value.is_a?(Hash) ? self.class.new(value.to_hash) : value
15
+ if value.is_a?(Array)
16
+ @attributes[key.to_sym] = value.map { |item| @attributes[key.to_sym] = item.is_a?(Hash) ? self.class.new(item.to_hash) : item }
17
+ else
18
+ @attributes[key.to_sym] = value.is_a?(Hash) ? self.class.new(value.to_hash) : value
19
+ end
13
20
  end
14
21
  end
15
22
 
@@ -17,20 +24,51 @@ module Tire
17
24
  # otherwise return +nil+.
18
25
  #
19
26
  def method_missing(method_name, *arguments)
20
- self.has_key?(method_name.to_sym) ? self[method_name.to_sym] : nil
27
+ @attributes.has_key?(method_name.to_sym) ? @attributes[method_name.to_sym] : nil
28
+ end
29
+
30
+ def [](key)
31
+ @attributes[key]
21
32
  end
22
33
 
23
- # Get ID
24
- #
25
34
  def id
26
- self[:id]
35
+ @attributes[:_id] || @attributes[:id]
36
+ end
37
+
38
+ def persisted?
39
+ !!id
40
+ end
41
+
42
+ def errors
43
+ ActiveModel::Errors.new(self)
44
+ end
45
+
46
+ def valid?
47
+ true
48
+ end
49
+
50
+ def to_key
51
+ persisted? ? [id] : nil
52
+ end
53
+
54
+ def to_hash
55
+ @attributes
56
+ end
57
+
58
+ # Let's pretend we're someone else in Rails
59
+ #
60
+ def class
61
+ defined?(::Rails) && @attributes[:_type] ? @attributes[:_type].camelize.constantize : super
27
62
  end
28
63
 
29
64
  def inspect
30
- s = []; self.each { |k,v| s << "#{k}: #{v.inspect}" }
31
- %Q|<Item #{s.join(', ')}>|
65
+ s = []; @attributes.each { |k,v| s << "#{k}: #{v.inspect}" }
66
+ %Q|<Item#{self.class.to_s == 'Tire::Results::Item' ? '' : " (#{self.class})"} #{s.join(', ')}>|
32
67
  end
33
68
 
69
+ def to_json(options=nil)
70
+ @attributes.to_json(options)
71
+ end
34
72
  alias_method :to_indexed_json, :to_json
35
73
 
36
74
  end
@@ -6,9 +6,6 @@ module Tire
6
6
  attr_reader :indices, :url, :results, :response, :json, :query, :facets, :filters, :options
7
7
 
8
8
  def initialize(indices=nil, options = {}, &block)
9
- Tire.warn "Passing indices as multiple arguments to the `Search.new` method " +
10
- "has been deprecated, please pass them as an Array: " +
11
- "Search.new([#{indices}, #{options}])" if options.is_a?(String)
12
9
  @indices = Array(indices)
13
10
  @options = options
14
11
  @type = @options[:type]
@@ -64,8 +61,8 @@ module Tire
64
61
  self
65
62
  end
66
63
 
67
- def fields(fields=[])
68
- @fields = fields
64
+ def fields(*fields)
65
+ @fields = Array(fields.flatten)
69
66
  self
70
67
  end
71
68
 
@@ -12,13 +12,6 @@ module Tire
12
12
  self
13
13
  end
14
14
 
15
- def method_missing(id, *args, &block)
16
- Tire.warn "Using methods when sorting has been deprecated, please use the `by` method: " +
17
- "sort { by :#{id}#{ args.empty? ? '' : ', ' + args.first.inspect } }"
18
-
19
- by id, args.shift
20
- end
21
-
22
15
  def to_ary
23
16
  @value
24
17
  end
@@ -1,13 +1,13 @@
1
1
  module Tire
2
- VERSION = "0.1.16"
2
+ VERSION = "0.2.0"
3
3
 
4
4
  CHANGELOG =<<-END
5
5
  IMPORTANT CHANGES LATELY:
6
6
 
7
- # Defined mapping for nested fields [#56]
8
- # Mapping type is optional and defaults to "string"
9
- # Fixed handling of fields returned prefixed by _source from ES [#31]
10
- # Allow passing the type to search and added that model passes `document_type` to search [@jonkarna, #38]
11
- # Allow leaving index name empty for searching the whole server
7
+ # By default, results are wrapped in Item class (05a1331)
8
+ # Completely rewritten ActiveModel/ActiveRecord support
9
+ # Added method to items for loading the "real" model from database (f9273bc)
10
+ # Added the ':load' option to eagerly load results from database (1e34cde)
11
+ # Deprecated the dynamic sort methods, use the 'sort { by :field_name }' syntax
12
12
  END
13
13
  end
@@ -16,7 +16,7 @@ module Tire
16
16
  SupermodelArticle.delete_all
17
17
  end
18
18
 
19
- context "ActiveModel" do
19
+ context "ActiveModel integration" do
20
20
 
21
21
  setup do
22
22
  Tire.index('supermodel_articles').delete
@@ -42,7 +42,6 @@ module Tire
42
42
  end
43
43
 
44
44
  a.index.refresh
45
- sleep(1.5)
46
45
 
47
46
  # The index should contain 2 documents
48
47
  assert_equal 2, Tire.search('supermodel_articles') { query { all } }.results.size
@@ -52,7 +51,7 @@ module Tire
52
51
  # The model should find only 1 document
53
52
  assert_equal 1, results.count
54
53
 
55
- assert_instance_of SupermodelArticle, results.first
54
+ assert_instance_of Results::Item, results.first
56
55
  assert_equal 'Test', results.first.title
57
56
  assert_not_nil results.first._score
58
57
  assert_equal id, results.first.id
@@ -61,11 +60,12 @@ module Tire
61
60
  should "remove document from index on destroy" do
62
61
  a = SupermodelArticle.new :title => 'Test'
63
62
  a.save
63
+ assert_equal 1, SupermodelArticle.all.size
64
+
64
65
  a.destroy
66
+ assert_equal 0, SupermodelArticle.all.size
65
67
 
66
68
  a.index.refresh
67
- sleep(1.25)
68
-
69
69
  results = SupermodelArticle.search 'test'
70
70
 
71
71
  assert_equal 0, results.count
@@ -84,6 +84,27 @@ module Tire
84
84
  assert_equal 'abc123', results.first.id
85
85
  end
86
86
 
87
+ context "within Rails" do
88
+
89
+ setup do
90
+ module ::Rails; end
91
+ end
92
+
93
+ should "load the underlying model" do
94
+ a = SupermodelArticle.new :title => 'Test'
95
+ a.save
96
+ a.index.refresh
97
+
98
+ results = SupermodelArticle.search 'test'
99
+
100
+ assert_instance_of Results::Item, results.first
101
+ assert_instance_of SupermodelArticle, results.first.load
102
+
103
+ assert_equal 'Test', results.first.load.title
104
+ end
105
+
106
+ end
107
+
87
108
  end
88
109
 
89
110
  end
@@ -15,19 +15,25 @@ module Tire
15
15
  t.string :title
16
16
  t.datetime :created_at, :default => 'NOW()'
17
17
  end
18
+ create_table :active_record_comments do |t|
19
+ t.string :author
20
+ t.text :body
21
+ t.references :article
22
+ t.timestamps
23
+ end
24
+ create_table :active_record_stats do |t|
25
+ t.integer :pageviews
26
+ t.string :period
27
+ t.references :article
28
+ end
18
29
  end
19
30
  end
20
31
 
21
- def teardown
22
- super
23
- File.delete fixtures_path.join('articles.db') rescue nil
24
- end
25
-
26
32
  context "ActiveRecord integration" do
27
33
 
28
34
  setup do
29
35
  Tire.index('active_record_articles').delete
30
- load File.expand_path('../../models/active_record_article.rb', __FILE__)
36
+ load File.expand_path('../../models/active_record_models.rb', __FILE__)
31
37
  end
32
38
  teardown { Tire.index('active_record_articles').delete }
33
39
 
@@ -44,24 +50,65 @@ module Tire
44
50
  id = a.id
45
51
 
46
52
  a.index.refresh
47
- sleep(1.5) # Leave ES some breathing room here...
48
53
 
49
54
  results = ActiveRecordArticle.search 'test'
50
55
 
56
+ assert results.any?
51
57
  assert_equal 1, results.count
52
58
 
53
- assert_instance_of ActiveRecordArticle, results.first
59
+ assert_instance_of Results::Item, results.first
54
60
  assert_not_nil results.first.id
55
- assert_equal id, results.first.id
61
+ assert_equal id.to_s, results.first.id.to_s
56
62
  assert results.first.persisted?, "Record should be persisted"
57
63
  assert_not_nil results.first._score
58
64
  assert_equal 'Test', results.first.title
59
65
  end
60
66
 
67
+ context "with eager loading" do
68
+ setup do
69
+ ActiveRecordArticle.destroy_all
70
+ 5.times { |n| ActiveRecordArticle.create! :title => "Test #{n+1}" }
71
+ ActiveRecordArticle.elasticsearch_index.refresh
72
+ end
73
+
74
+ should "load records on query search" do
75
+ results = ActiveRecordArticle.search '"Test 1"', :load => true
76
+
77
+ assert results.any?
78
+ assert_equal ActiveRecordArticle.find(1), results.first
79
+ end
80
+
81
+ should "load records on block search" do
82
+ results = ActiveRecordArticle.search :load => true do
83
+ query { string '"Test 1"' }
84
+ end
85
+
86
+ assert_equal ActiveRecordArticle.find(1), results.first
87
+ end
88
+
89
+ should "load records with options on query search" do
90
+ assert_equal ActiveRecordArticle.find(['1', '2'], :include => 'comments'),
91
+ ActiveRecordArticle.search('"Test 1" OR "Test 2"', :load => { :include => 'comments' }).results
92
+ end
93
+
94
+ should "return empty collection for nonmatching query" do
95
+ assert_nothing_raised do
96
+ results = ActiveRecordArticle.search :load => true do
97
+ query { string '"Hic Sunt Leones"' }
98
+ end
99
+ assert_equal 0, results.size
100
+ assert ! results.any?
101
+ end
102
+ end
103
+ end
104
+
61
105
  should "remove document from index on destroy" do
62
106
  a = ActiveRecordArticle.new :title => 'Test'
63
107
  a.save!
108
+ assert_equal 1, ActiveRecordArticle.count
109
+
64
110
  a.destroy
111
+ assert_equal 0, SupermodelArticle.all.size
65
112
 
66
113
  a.index.refresh
67
114
  results = ActiveRecordArticle.search 'test'
@@ -184,6 +231,44 @@ module Tire
184
231
 
185
232
  end
186
233
 
234
+ context "within Rails" do
235
+
236
+ setup do
237
+ module ::Rails; end
238
+
239
+ a = ActiveRecordArticle.new :title => 'Test'
240
+ a.comments.build :author => 'fool', :body => 'Works!'
241
+ a.stats.build :pageviews => 12, :period => '2011-08'
242
+ a.save!
243
+ @id = a.id.to_s
244
+
245
+ a.index.refresh
246
+ @item = ActiveRecordArticle.search('test').first
247
+ end
248
+
249
+ should "have access to indexed properties" do
250
+ assert_equal 'Test', @item.title
251
+ assert_equal 'fool', @item.comments.first.author
252
+ assert_equal 12, @item.stats.first.pageviews
253
+ end
254
+
255
+ should "load the underlying models" do
256
+ assert_instance_of Results::Item, @item
257
+ assert_instance_of ActiveRecordArticle, @item.load
258
+ assert_equal 'Test', @item.load.title
259
+
260
+ assert_instance_of Results::Item, @item.comments.first
261
+ assert_instance_of ActiveRecordComment, @item.comments.first.load
262
+ assert_equal 'fool', @item.comments.first.load.author
263
+ end
264
+
265
+ should "load the underlying model with options" do
266
+ ActiveRecordArticle.expects(:find).with(@id, :include => 'comments')
267
+ @item.load(:include => 'comments')
268
+ end
269
+
270
+ end
271
+
187
272
  end
188
273
 
189
274
  end