freelancing-god-thinking-sphinx 1.1.24 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. data/README.textile +1 -0
  2. data/lib/thinking_sphinx.rb +8 -5
  3. data/lib/thinking_sphinx/active_record.rb +5 -4
  4. data/lib/thinking_sphinx/active_record/scopes.rb +37 -0
  5. data/lib/thinking_sphinx/configuration.rb +6 -0
  6. data/lib/thinking_sphinx/excerpter.rb +22 -0
  7. data/lib/thinking_sphinx/facet_search.rb +134 -0
  8. data/lib/thinking_sphinx/search.rb +590 -673
  9. data/lib/thinking_sphinx/search_methods.rb +421 -0
  10. data/lib/thinking_sphinx/tasks.rb +3 -3
  11. data/spec/{unit → lib}/thinking_sphinx/active_record/delta_spec.rb +0 -0
  12. data/spec/{unit → lib}/thinking_sphinx/active_record/has_many_association_spec.rb +0 -0
  13. data/spec/lib/thinking_sphinx/active_record/scopes_spec.rb +92 -0
  14. data/spec/{unit → lib}/thinking_sphinx/active_record_spec.rb +0 -0
  15. data/spec/{unit → lib}/thinking_sphinx/association_spec.rb +0 -0
  16. data/spec/{unit → lib}/thinking_sphinx/attribute_spec.rb +0 -0
  17. data/spec/{unit → lib}/thinking_sphinx/configuration_spec.rb +25 -0
  18. data/spec/{unit → lib}/thinking_sphinx/core/string_spec.rb +0 -0
  19. data/spec/lib/thinking_sphinx/excerpter_spec.rb +49 -0
  20. data/spec/lib/thinking_sphinx/facet_search_spec.rb +176 -0
  21. data/spec/{unit → lib}/thinking_sphinx/facet_spec.rb +0 -0
  22. data/spec/{unit → lib}/thinking_sphinx/field_spec.rb +0 -0
  23. data/spec/{unit → lib}/thinking_sphinx/index/builder_spec.rb +0 -0
  24. data/spec/{unit → lib}/thinking_sphinx/index/faux_column_spec.rb +0 -0
  25. data/spec/{unit → lib}/thinking_sphinx/index_spec.rb +0 -0
  26. data/spec/{unit → lib}/thinking_sphinx/rails_additions_spec.rb +0 -0
  27. data/spec/lib/thinking_sphinx/search_methods_spec.rb +152 -0
  28. data/spec/lib/thinking_sphinx/search_spec.rb +879 -0
  29. data/spec/{unit → lib}/thinking_sphinx/source_spec.rb +0 -0
  30. data/spec/{unit → lib}/thinking_sphinx_spec.rb +0 -0
  31. data/vendor/riddle/lib/riddle/client.rb +3 -0
  32. data/vendor/riddle/lib/riddle/configuration/section.rb +1 -1
  33. data/vendor/riddle/lib/riddle/controller.rb +1 -1
  34. metadata +46 -44
  35. data/lib/thinking_sphinx/active_record/search.rb +0 -57
  36. data/lib/thinking_sphinx/collection.rb +0 -148
  37. data/lib/thinking_sphinx/facet_collection.rb +0 -59
  38. data/lib/thinking_sphinx/search/facets.rb +0 -104
  39. data/spec/unit/thinking_sphinx/active_record/search_spec.rb +0 -107
  40. data/spec/unit/thinking_sphinx/collection_spec.rb +0 -15
  41. data/spec/unit/thinking_sphinx/facet_collection_spec.rb +0 -64
  42. data/spec/unit/thinking_sphinx/search_spec.rb +0 -228
data/README.textile CHANGED
@@ -144,3 +144,4 @@ Since I first released this library, there's been quite a few people who have su
144
144
  * Erik Ostrom
145
145
  * Ole Riesenberg
146
146
  * Josh Kalderimis
147
+ * J.D. Hollis
@@ -11,16 +11,17 @@ require 'thinking_sphinx/property'
11
11
  require 'thinking_sphinx/active_record'
12
12
  require 'thinking_sphinx/association'
13
13
  require 'thinking_sphinx/attribute'
14
- require 'thinking_sphinx/collection'
15
14
  require 'thinking_sphinx/configuration'
15
+ require 'thinking_sphinx/excerpter'
16
16
  require 'thinking_sphinx/facet'
17
17
  require 'thinking_sphinx/class_facet'
18
- require 'thinking_sphinx/facet_collection'
18
+ require 'thinking_sphinx/facet_search'
19
19
  require 'thinking_sphinx/field'
20
20
  require 'thinking_sphinx/index'
21
21
  require 'thinking_sphinx/source'
22
22
  require 'thinking_sphinx/rails_additions'
23
23
  require 'thinking_sphinx/search'
24
+ require 'thinking_sphinx/search_methods'
24
25
  require 'thinking_sphinx/deltas'
25
26
 
26
27
  require 'thinking_sphinx/adapters/abstract_adapter'
@@ -36,8 +37,8 @@ Merb::Plugins.add_rakefiles(
36
37
  module ThinkingSphinx
37
38
  module Version #:nodoc:
38
39
  Major = 1
39
- Minor = 1
40
- Tiny = 24
40
+ Minor = 2
41
+ Tiny = 0
41
42
 
42
43
  String = [Major, Minor, Tiny].join('.')
43
44
  end
@@ -184,7 +185,7 @@ module ThinkingSphinx
184
185
  cat_command = 'type'
185
186
  end
186
187
 
187
- `#{cat_command} #{pid_file}`[/\d+/]
188
+ `#{cat_command} \"#{pid_file}\"`[/\d+/]
188
189
  end
189
190
 
190
191
  def self.pid_active?(pid)
@@ -212,4 +213,6 @@ module ThinkingSphinx
212
213
  jruby? && ::ActiveRecord::Base.connection.config[:adapter] == "jdbcmysql"
213
214
  )
214
215
  end
216
+
217
+ extend ThinkingSphinx::SearchMethods::ClassMethods
215
218
  end
@@ -1,7 +1,7 @@
1
1
  require 'thinking_sphinx/active_record/attribute_updates'
2
2
  require 'thinking_sphinx/active_record/delta'
3
- require 'thinking_sphinx/active_record/search'
4
3
  require 'thinking_sphinx/active_record/has_many_association'
4
+ require 'thinking_sphinx/active_record/scopes'
5
5
 
6
6
  module ThinkingSphinx
7
7
  # Core additions to ActiveRecord models - define_index for creating indexes
@@ -80,7 +80,9 @@ module ThinkingSphinx
80
80
 
81
81
  after_destroy :toggle_deleted
82
82
 
83
+ include ThinkingSphinx::SearchMethods
83
84
  include ThinkingSphinx::ActiveRecord::AttributeUpdates
85
+ include ThinkingSphinx::ActiveRecord::Scopes
84
86
 
85
87
  index
86
88
 
@@ -140,12 +142,12 @@ module ThinkingSphinx
140
142
  ThinkingSphinx::AbstractAdapter.detect(self)
141
143
  end
142
144
 
143
- private
144
-
145
145
  def sphinx_name
146
146
  self.name.underscore.tr(':/\\', '_')
147
147
  end
148
148
 
149
+ private
150
+
149
151
  def sphinx_delta?
150
152
  self.sphinx_indexes.any? { |index| index.delta? }
151
153
  end
@@ -215,7 +217,6 @@ module ThinkingSphinx
215
217
  end
216
218
 
217
219
  base.send(:include, ThinkingSphinx::ActiveRecord::Delta)
218
- base.send(:include, ThinkingSphinx::ActiveRecord::Search)
219
220
 
220
221
  ::ActiveRecord::Associations::HasManyAssociation.send(
221
222
  :include, ThinkingSphinx::ActiveRecord::HasManyAssociation
@@ -0,0 +1,37 @@
1
+ module ThinkingSphinx
2
+ module ActiveRecord
3
+ module Scopes
4
+ def self.included(base)
5
+ base.class_eval do
6
+ extend ThinkingSphinx::ActiveRecord::Scopes::ClassMethods
7
+ end
8
+ end
9
+
10
+ module ClassMethods
11
+ def sphinx_scope(method, &block)
12
+ @sphinx_scopes ||= []
13
+ @sphinx_scopes << method
14
+
15
+ metaclass.instance_eval do
16
+ define_method(method) do |*args|
17
+ options = block.call(*args)
18
+ ThinkingSphinx::Search.new(options)
19
+ end
20
+ end
21
+ end
22
+
23
+ def sphinx_scopes
24
+ @sphinx_scopes || []
25
+ end
26
+
27
+ def remove_sphinx_scopes
28
+ sphinx_scopes.each do |scope|
29
+ metaclass.send(:undef_method, scope)
30
+ end
31
+
32
+ sphinx_scopes.clear
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -221,6 +221,12 @@ module ThinkingSphinx
221
221
  @configuration.searchd.query_log = file
222
222
  end
223
223
 
224
+ def client
225
+ client = Riddle::Client.new address, port
226
+ client.max_matches = configuration.searchd.max_matches || 1000
227
+ client
228
+ end
229
+
224
230
  private
225
231
 
226
232
  # Parse the config/sphinx.yml file - if it exists - then use the attribute
@@ -0,0 +1,22 @@
1
+ module ThinkingSphinx
2
+ class Excerpter
3
+ CoreMethods = %w( kind_of? object_id respond_to? should should_not stub! )
4
+ # Hide most methods, to allow them to be passed through to the instance.
5
+ instance_methods.select { |method|
6
+ method.to_s[/^__/].nil? && !CoreMethods.include?(method.to_s)
7
+ }.each { |method|
8
+ undef_method method
9
+ }
10
+
11
+ def initialize(search, instance)
12
+ @search = search
13
+ @instance = instance
14
+ end
15
+
16
+ def method_missing(method, *args, &block)
17
+ string = @instance.send(method, *args, &block).to_s
18
+
19
+ @search.excerpt_for(string, @instance.class)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,134 @@
1
+ module ThinkingSphinx
2
+ class FacetSearch < Hash
3
+ attr_accessor :args, :options
4
+
5
+ def initialize(*args)
6
+ @options = args.extract_options!
7
+ @args = args
8
+
9
+ set_default_options
10
+
11
+ populate
12
+ end
13
+
14
+ def for(hash = {})
15
+ for_options = {:with => {}}.merge(options)
16
+
17
+ hash.each do |key, value|
18
+ attrib = ThinkingSphinx::Facet.attribute_name_from_value(key, value)
19
+ for_options[:with][attrib] = underlying_value key, value
20
+ end
21
+
22
+ ThinkingSphinx.search *(args + [for_options])
23
+ end
24
+
25
+ def facet_names
26
+ @facet_names ||= begin
27
+ names = options[:all_facets] ?
28
+ facet_names_for_all_classes : facet_names_common_to_all_classes
29
+
30
+ names.delete "class_crc" unless options[:class_facet]
31
+ names
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def set_default_options
38
+ options[:all_facets] ||= false
39
+ if options[:class_facet].nil?
40
+ options[:class_facet] = ((options[:classes] || []).length != 1)
41
+ end
42
+ end
43
+
44
+ def populate
45
+ facet_names.each do |name|
46
+ search_options = facet_search_options.merge(:group_by => name)
47
+ add_from_results name, ThinkingSphinx.search(
48
+ *(args + [search_options])
49
+ )
50
+ end
51
+ end
52
+
53
+ def facet_search_options
54
+ config = ThinkingSphinx::Configuration.instance
55
+ max = config.configuration.searchd.max_matches || 1000
56
+
57
+ options.merge(
58
+ :group_function => :attr,
59
+ :limit => max,
60
+ :max_matches => max,
61
+ :page => 1
62
+ )
63
+ end
64
+
65
+ def facet_classes
66
+ (
67
+ options[:classes] || ThinkingSphinx.indexed_models.collect { |model|
68
+ model.constantize
69
+ }
70
+ ).select { |klass| klass.sphinx_facets.any? }
71
+ end
72
+
73
+ def all_facets
74
+ facet_classes.collect { |klass|
75
+ klass.sphinx_facets
76
+ }.flatten.select { |facet|
77
+ options[:facets].blank? || Array(options[:facets]).include?(facet.name)
78
+ }
79
+ end
80
+
81
+ def facet_names_for_all_classes
82
+ all_facets.group_by { |facet|
83
+ facet.name
84
+ }.collect { |name, facets|
85
+ if facets.collect { |facet| facet.type }.uniq.length > 1
86
+ raise "Facet #{name} exists in more than one model with different types"
87
+ end
88
+ facets.first.attribute_name
89
+ }
90
+ end
91
+
92
+ def facet_names_common_to_all_classes
93
+ facet_names_for_all_classes.select { |name|
94
+ facet_classes.all? { |klass|
95
+ klass.sphinx_facets.detect { |facet|
96
+ facet.attribute_name == name
97
+ }
98
+ }
99
+ }
100
+ end
101
+
102
+ def add_from_results(facet, results)
103
+ name = ThinkingSphinx::Facet.name_for(facet)
104
+
105
+ self[name] ||= {}
106
+
107
+ return if results.empty?
108
+
109
+ facet = facet_from_object(results.first, facet) if facet.is_a?(String)
110
+
111
+ results.each_with_groupby_and_count { |result, group, count|
112
+ facet_value = facet.value(result, group)
113
+
114
+ self[name][facet_value] ||= 0
115
+ self[name][facet_value] += count
116
+ }
117
+ end
118
+
119
+ def underlying_value(key, value)
120
+ case value
121
+ when Array
122
+ value.collect { |item| underlying_value(key, item) }
123
+ when String
124
+ value.to_crc32
125
+ else
126
+ value
127
+ end
128
+ end
129
+
130
+ def facet_from_object(object, name)
131
+ object.sphinx_facets.detect { |facet| facet.attribute_name == name }
132
+ end
133
+ end
134
+ end
@@ -1,5 +1,4 @@
1
- require 'thinking_sphinx/search/facets'
2
-
1
+ # encoding: UTF-8
3
2
  module ThinkingSphinx
4
3
  # Once you've got those indexes in and built, this is the stuff that
5
4
  # matters - how to search! This class provides a generic search
@@ -9,718 +8,636 @@ module ThinkingSphinx
9
8
  # called from a model.
10
9
  #
11
10
  class Search
12
- GlobalFacetOptions = {
13
- :all_attributes => false,
14
- :class_facet => true
11
+ CoreMethods = %w( == class class_eval extend frozen? id instance_eval
12
+ instance_of? instance_values instance_variable_defined?
13
+ instance_variable_get instance_variable_set instance_variables is_a?
14
+ kind_of? member? method methods nil? object_id respond_to? send should
15
+ type )
16
+ SafeMethods = %w( partition private_methods protected_methods
17
+ public_methods send )
18
+
19
+ instance_methods.select { |method|
20
+ method.to_s[/^__/].nil? && !CoreMethods.include?(method.to_s)
21
+ }.each { |method|
22
+ undef_method method
15
23
  }
16
24
 
17
- class << self
18
- include ThinkingSphinx::Search::Facets
25
+ HashOptions = [:conditions, :with, :without, :with_all]
26
+ ArrayOptions = [:classes, :without_ids]
27
+
28
+ attr_reader :args, :options, :results
29
+
30
+ # Deprecated. Use ThinkingSphinx.search
31
+ def self.search(*args)
32
+ log 'ThinkingSphinx::Search.search is deprecated. Please use ThinkingSphinx.search instead.'
33
+ ThinkingSphinx.search *args
34
+ end
35
+
36
+ # Deprecated. Use ThinkingSphinx.search_for_ids
37
+ def self.search_for_ids(*args)
38
+ log 'ThinkingSphinx::Search.search_for_ids is deprecated. Please use ThinkingSphinx.search_for_ids instead.'
39
+ ThinkingSphinx.search_for_ids *args
40
+ end
41
+
42
+ # Deprecated. Use ThinkingSphinx.search_for_ids
43
+ def self.search_for_id(*args)
44
+ log 'ThinkingSphinx::Search.search_for_id is deprecated. Please use ThinkingSphinx.search_for_id instead.'
45
+ ThinkingSphinx.search_for_id *args
46
+ end
47
+
48
+ # Deprecated. Use ThinkingSphinx.count
49
+ def self.count(*args)
50
+ log 'ThinkingSphinx::Search.count is deprecated. Please use ThinkingSphinx.count instead.'
51
+ ThinkingSphinx.count *args
52
+ end
53
+
54
+ # Deprecated. Use ThinkingSphinx.facets
55
+ def self.facets(*args)
56
+ log 'ThinkingSphinx::Search.facets is deprecated. Please use ThinkingSphinx.facets instead.'
57
+ ThinkingSphinx.facets *args
58
+ end
59
+
60
+ def initialize(*args)
61
+ @array = []
62
+ @options = args.extract_options!
63
+ @args = args
64
+ end
65
+
66
+ def to_a
67
+ populate
68
+ @array
69
+ end
70
+
71
+ def method_missing(method, *args, &block)
72
+ if is_scope?(method)
73
+ add_scope(method, *args, &block)
74
+ return self
75
+ elsif method.to_s[/^each_with_.*/].nil? && !@array.respond_to?(method)
76
+ super
77
+ elsif !SafeMethods.include?(method.to_s)
78
+ populate
79
+ end
19
80
 
20
- # Searches for results that match the parameters provided. Will only
21
- # return the ids for the matching objects. See #search for syntax
22
- # examples.
23
- #
24
- # Note that this only searches the Sphinx index, with no ActiveRecord
25
- # queries. Thus, if your index is not in sync with the database, this
26
- # method may return ids that no longer exist there.
27
- #
28
- def search_for_ids(*args)
29
- results, client = search_results(*args.clone)
30
-
31
- options = args.extract_options!
32
- page = options[:page] ? options[:page].to_i : 1
33
-
34
- ThinkingSphinx::Collection.ids_from_results(results, page, client.limit, options)
81
+ if method.to_s[/^each_with_.*/] && !@array.respond_to?(method)
82
+ each_with_attribute method.to_s.gsub(/^each_with_/, ''), &block
83
+ else
84
+ @array.send(method, *args, &block)
35
85
  end
36
-
37
- # Searches through the Sphinx indexes for relevant matches. There's
38
- # various ways to search, sort, group and filter - which are covered
39
- # below.
40
- #
41
- # Also, if you have WillPaginate installed, the search method can be used
42
- # just like paginate. The same parameters - :page and :per_page - work as
43
- # expected, and the returned result set can be used by the will_paginate
44
- # helper.
45
- #
46
- # == Basic Searching
47
- #
48
- # The simplest way of searching is straight text.
49
- #
50
- # ThinkingSphinx::Search.search "pat"
51
- # ThinkingSphinx::Search.search "google"
52
- # User.search "pat", :page => (params[:page] || 1)
53
- # Article.search "relevant news issue of the day"
54
- #
55
- # If you specify :include, like in an #find call, this will be respected
56
- # when loading the relevant models from the search results.
57
- #
58
- # User.search "pat", :include => :posts
59
- #
60
- # == Match Modes
61
- #
62
- # Sphinx supports 5 different matching modes. By default Thinking Sphinx
63
- # uses :all, which unsurprisingly requires all the supplied search terms
64
- # to match a result.
65
- #
66
- # Alternative modes include:
67
- #
68
- # User.search "pat allan", :match_mode => :any
69
- # User.search "pat allan", :match_mode => :phrase
70
- # User.search "pat | allan", :match_mode => :boolean
71
- # User.search "@name pat | @username pat", :match_mode => :extended
72
- #
73
- # Any will find results with any of the search terms. Phrase treats the search
74
- # terms a single phrase instead of individual words. Boolean and extended allow
75
- # for more complex query syntax, refer to the sphinx documentation for further
76
- # details.
77
- #
78
- # == Weighting
79
- #
80
- # Sphinx has support for weighting, where matches in one field can be considered
81
- # more important than in another. Weights are integers, with 1 as the default.
82
- # They can be set per-search like this:
83
- #
84
- # User.search "pat allan", :field_weights => { :alias => 4, :aka => 2 }
85
- #
86
- # If you're searching multiple models, you can set per-index weights:
87
- #
88
- # ThinkingSphinx::Search.search "pat", :index_weights => { User => 10 }
89
- #
90
- # See http://sphinxsearch.com/doc.html#weighting for further details.
91
- #
92
- # == Searching by Fields
93
- #
94
- # If you want to step it up a level, you can limit your search terms to
95
- # specific fields:
96
- #
97
- # User.search :conditions => {:name => "pat"}
98
- #
99
- # This uses Sphinx's extended match mode, unless you specify a different
100
- # match mode explicitly (but then this way of searching won't work). Also
101
- # note that you don't need to put in a search string.
102
- #
103
- # == Searching by Attributes
104
- #
105
- # Also known as filters, you can limit your searches to documents that
106
- # have specific values for their attributes. There are three ways to do
107
- # this. The first two techniques work in all scenarios - using the :with
108
- # or :with_all options.
109
- #
110
- # ThinkingSphinx::Search.search :with => {:tag_ids => 10}
111
- # ThinkingSphinx::Search.search :with => {:tag_ids => [10,12]}
112
- # ThinkingSphinx::Search.search :with_all => {:tag_ids => [10,12]}
113
- #
114
- # The first :with search will match records with a tag_id attribute of 10.
115
- # The second :with will match records with a tag_id attribute of 10 OR 12.
116
- # If you need to find records that are tagged with ids 10 AND 12, you
117
- # will need to use the :with_all search parameter. This is particuarly
118
- # useful in conjunction with Multi Value Attributes (MVAs).
119
- #
120
- # The third filtering technique is only viable if you're searching with a
121
- # specific model (not multi-model searching). With a single model,
122
- # Thinking Sphinx can figure out what attributes and fields are available,
123
- # so you can put it all in the :conditions hash, and it will sort it out.
124
- #
125
- # Node.search :conditions => {:parent_id => 10}
126
- #
127
- # Filters can be single values, arrays of values, or ranges.
128
- #
129
- # Article.search "East Timor", :conditions => {:rating => 3..5}
130
- #
131
- # == Excluding by Attributes
132
- #
133
- # Sphinx also supports negative filtering - where the filters are of
134
- # attribute values to exclude. This is done with the :without option:
135
- #
136
- # User.search :without => {:role_id => 1}
137
- #
138
- # == Excluding by Primary Key
139
- #
140
- # There is a shortcut to exclude records by their ActiveRecord primary key:
141
- #
142
- # User.search :without_ids => 1
143
- #
144
- # Pass an array or a single value.
145
- #
146
- # The primary key must be an integer as a negative filter is used. Note
147
- # that for multi-model search, an id may occur in more than one model.
148
- #
149
- # == Infix (Star) Searching
150
- #
151
- # By default, Sphinx uses English stemming, e.g. matching "shoes" if you
152
- # search for "shoe". It won't find "Melbourne" if you search for
153
- # "elbourn", though.
154
- #
155
- # Enable infix searching by something like this in config/sphinx.yml:
156
- #
157
- # development:
158
- # enable_star: 1
159
- # min_infix_length: 2
160
- #
161
- # Note that this will make indexing take longer.
162
- #
163
- # With those settings (and after reindexing), wildcard asterisks can be used
164
- # in queries:
165
- #
166
- # Location.search "*elbourn*"
167
- #
168
- # To automatically add asterisks around every token (but not operators),
169
- # pass the :star option:
170
- #
171
- # Location.search "elbourn -ustrali", :star => true, :match_mode => :boolean
172
- #
173
- # This would become "*elbourn* -*ustrali*". The :star option only adds the
174
- # asterisks. You need to make the config/sphinx.yml changes yourself.
175
- #
176
- # By default, the tokens are assumed to match the regular expression /\w+/u.
177
- # If you've modified the charset_table, pass another regular expression, e.g.
178
- #
179
- # User.search("oo@bar.c", :star => /[\w@.]+/u)
180
- #
181
- # to search for "*oo@bar.c*" and not "*oo*@*bar*.*c*".
182
- #
183
- # == Sorting
184
- #
185
- # Sphinx can only sort by attributes, so generally you will need to avoid
186
- # using field names in your :order option. However, if you're searching
187
- # on a single model, and have specified some fields as sortable, you can
188
- # use those field names and Thinking Sphinx will interpret accordingly.
189
- # Remember: this will only happen for single-model searches, and only
190
- # through the :order option.
191
- #
192
- # Location.search "Melbourne", :order => :state
193
- # User.search :conditions => {:role_id => 2}, :order => "name ASC"
194
- #
195
- # Keep in mind that if you use a string, you *must* specify the direction
196
- # (ASC or DESC) else Sphinx won't return any results. If you use a symbol
197
- # then Thinking Sphinx assumes ASC, but if you wish to state otherwise,
198
- # use the :sort_mode option:
199
- #
200
- # Location.search "Melbourne", :order => :state, :sort_mode => :desc
201
- #
202
- # Of course, there are other sort modes - check out the Sphinx
203
- # documentation[http://sphinxsearch.com/doc.html] for that level of
204
- # detail though.
205
- #
206
- # If desired, you can sort by a column in your model instead of a sphinx
207
- # field or attribute. This sort only applies to the current page, so is
208
- # most useful when performing a search with a single page of results.
209
- #
210
- # User.search("pat", :sql_order => "name")
211
- #
212
- # == Grouping
213
- #
214
- # For this you can use the group_by, group_clause and group_function
215
- # options - which are all directly linked to Sphinx's expectations. No
216
- # magic from Thinking Sphinx. It can get a little tricky, so make sure
217
- # you read all the relevant
218
- # documentation[http://sphinxsearch.com/doc.html#clustering] first.
219
- #
220
- # Grouping is done via three parameters within the options hash
221
- # * <tt>:group_function</tt> determines the way grouping is done
222
- # * <tt>:group_by</tt> determines the field which is used for grouping
223
- # * <tt>:group_clause</tt> determines the sorting order
224
- #
225
- # As a convenience, you can also use
226
- # * <tt>:group</tt>
227
- # which sets :group_by and defaults to :group_function of :attr
228
- #
229
- # === group_function
230
- #
231
- # Valid values for :group_function are
232
- # * <tt>:day</tt>, <tt>:week</tt>, <tt>:month</tt>, <tt>:year</tt> - Grouping is done by the respective timeframes.
233
- # * <tt>:attr</tt>, <tt>:attrpair</tt> - Grouping is done by the specified attributes(s)
234
- #
235
- # === group_by
236
- #
237
- # This parameter denotes the field by which grouping is done. Note that the
238
- # specified field must be a sphinx attribute or index.
239
- #
240
- # === group_clause
241
- #
242
- # This determines the sorting order of the groups. In a grouping search,
243
- # the matches within a group will sorted by the <tt>:sort_mode</tt> and <tt>:order</tt> parameters.
244
- # The group matches themselves however, will be sorted by <tt>:group_clause</tt>.
245
- #
246
- # The syntax for this is the same as an order parameter in extended sort mode.
247
- # Namely, you can specify an SQL-like sort expression with up to 5 attributes
248
- # (including internal attributes), eg: "@relevance DESC, price ASC, @id DESC"
249
- #
250
- # === Grouping by timestamp
251
- #
252
- # Timestamp grouping groups off items by the day, week, month or year of the
253
- # attribute given. In order to do this you need to define a timestamp attribute,
254
- # which pretty much looks like the standard defintion for any attribute.
255
- #
256
- # define_index do
257
- # #
258
- # # All your other stuff
259
- # #
260
- # has :created_at
261
- # end
262
- #
263
- # When you need to fire off your search, it'll go something to the tune of
264
- #
265
- # Fruit.search "apricot", :group_function => :day, :group_by => 'created_at'
266
- #
267
- # The <tt>@groupby</tt> special attribute will contain the date for that group.
268
- # Depending on the <tt>:group_function</tt> parameter, the date format will be
269
- #
270
- # * <tt>:day</tt> - YYYYMMDD
271
- # * <tt>:week</tt> - YYYYNNN (NNN is the first day of the week in question,
272
- # counting from the start of the year )
273
- # * <tt>:month</tt> - YYYYMM
274
- # * <tt>:year</tt> - YYYY
275
- #
276
- #
277
- # === Grouping by attribute
278
- #
279
- # The syntax is the same as grouping by timestamp, except for the fact that the
280
- # <tt>:group_function</tt> parameter is changed
281
- #
282
- # Fruit.search "apricot", :group_function => :attr, :group_by => 'size'
283
- #
284
- #
285
- # == Geo/Location Searching
286
- #
287
- # Sphinx - and therefore Thinking Sphinx - has the facility to search
288
- # around a geographical point, using a given latitude and longitude. To
289
- # take advantage of this, you will need to have both of those values in
290
- # attributes. To search with that point, you can then use one of the
291
- # following syntax examples:
292
- #
293
- # Address.search "Melbourne", :geo => [1.4, -2.217], :order => "@geodist asc"
294
- # Address.search "Australia", :geo => [-0.55, 3.108], :order => "@geodist asc"
295
- # :latitude_attr => "latit", :longitude_attr => "longit"
296
- #
297
- # The first example applies when your latitude and longitude attributes
298
- # are named any of lat, latitude, lon, long or longitude. If that's not
299
- # the case, you will need to explicitly state them in your search, _or_
300
- # you can do so in your model:
301
- #
302
- # define_index do
303
- # has :latit # Float column, stored in radians
304
- # has :longit # Float column, stored in radians
305
- #
306
- # set_property :latitude_attr => "latit"
307
- # set_property :longitude_attr => "longit"
308
- # end
309
- #
310
- # Now, geo-location searching really only has an affect if you have a
311
- # filter, sort or grouping clause related to it - otherwise it's just a
312
- # normal search, and _will not_ return a distance value otherwise. To
313
- # make use of the positioning difference, use the special attribute
314
- # "@geodist" in any of your filters or sorting or grouping clauses.
315
- #
316
- # And don't forget - both the latitude and longitude you use in your
317
- # search, and the values in your indexes, need to be stored as a float in radians,
318
- # _not_ degrees. Keep in mind that if you do this conversion in SQL
319
- # you will need to explicitly declare a column type of :float.
320
- #
321
- # define_index do
322
- # has 'RADIANS(lat)', :as => :lat, :type => :float
323
- # # ...
324
- # end
325
- #
326
- # Once you've got your results set, you can access the distances as
327
- # follows:
328
- #
329
- # @results.each_with_geodist do |result, distance|
330
- # # ...
331
- # end
332
- #
333
- # The distance value is returned as a float, representing the distance in
334
- # metres.
335
- #
336
- # == Handling a Stale Index
337
- #
338
- # Especially if you don't use delta indexing, you risk having records in the
339
- # Sphinx index that are no longer in the database. By default, those will simply
340
- # come back as nils:
341
- #
342
- # >> pat_user.delete
343
- # >> User.search("pat")
344
- # Sphinx Result: [1,2]
345
- # => [nil, <#User id: 2>]
346
- #
347
- # (If you search across multiple models, you'll get ActiveRecord::RecordNotFound.)
348
- #
349
- # You can simply Array#compact these results or handle the nils in some other way, but
350
- # Sphinx will still report two results, and the missing records may upset your layout.
351
- #
352
- # If you pass :retry_stale => true to a single-model search, missing records will
353
- # cause Thinking Sphinx to retry the query but excluding those records. Since search
354
- # is paginated, the new search could potentially include missing records as well, so by
355
- # default Thinking Sphinx will retry three times. Pass :retry_stale => 5 to retry five
356
- # times, and so on. If there are still missing ids on the last retry, they are
357
- # shown as nils.
358
- #
359
- def search(*args)
360
- query = args.clone # an array
361
- options = query.extract_options!
362
-
363
- retry_search_on_stale_index(query, options) do
364
- results, client = search_results(*(query + [options]))
365
-
366
- log "Sphinx Error: #{results[:error]}", :error if results[:error]
367
-
368
- klass = options[:class]
369
- page = options[:page] ? options[:page].to_i : 1
86
+ end
370
87
 
371
- ThinkingSphinx::Collection.create_from_results(results, page, client.limit, options)
372
- end
373
- end
374
-
375
- def retry_search_on_stale_index(query, options, &block)
376
- stale_ids = []
377
- stale_retries_left = case options[:retry_stale]
378
- when true
379
- 3 # default to three retries
380
- when nil, false
381
- 0 # no retries
382
- else options[:retry_stale].to_i
383
- end
384
- begin
385
- # Passing this in an option so Collection.create_from_results can see it.
386
- # It should only raise on stale records if there are any retries left.
387
- options[:raise_on_stale] = stale_retries_left > 0
388
- block.call
389
- # If ThinkingSphinx::Collection.create_from_results found records in Sphinx but not
390
- # in the DB and the :raise_on_stale option is set, this exception is raised. We retry
391
- # a limited number of times, excluding the stale ids from the search.
392
- rescue StaleIdsException => e
393
- stale_retries_left -= 1
394
-
395
- stale_ids |= e.ids # For logging
396
- options[:without_ids] = Array(options[:without_ids]) | e.ids # Actual exclusion
397
-
398
- tries = stale_retries_left
399
- log "Sphinx Stale Ids (%s %s left): %s" % [
400
- tries, (tries==1 ? 'try' : 'tries'), stale_ids.join(', ')
401
- ]
402
-
403
- retry
404
- end
88
+ # Returns true if the Search object or the underlying Array object respond
89
+ # to the requested method.
90
+ #
91
+ # @param [Symbol] method The method name
92
+ # @return [Boolean] true if either Search or Array responds to the method.
93
+ #
94
+ def respond_to?(method)
95
+ super || @array.respond_to?(method)
96
+ end
97
+
98
+ # The current page number of the result set. Defaults to 1 if no page was
99
+ # explicitly requested.
100
+ #
101
+ # @return [Integer]
102
+ #
103
+ def current_page
104
+ @options[:page] || 1
105
+ end
106
+
107
+ # The next page number of the result set. If there are no more pages
108
+ # available, nil is returned.
109
+ #
110
+ # @return [Integer, nil]
111
+ #
112
+ def next_page
113
+ current_page >= total_pages ? nil : current_page + 1
114
+ end
115
+
116
+ # The previous page number of the result set. If this is the first page,
117
+ # then nil is returned.
118
+ #
119
+ # @return [Integer, nil]
120
+ #
121
+ def previous_page
122
+ current_page == 1 ? nil : current_page - 1
123
+ end
124
+
125
+ # The amount of records per set of paged results. Defaults to 20 unless a
126
+ # specific page size is requested.
127
+ #
128
+ # @return [Integer]
129
+ #
130
+ def per_page
131
+ @options[:limit] || @options[:per_page] || 20
132
+ end
133
+
134
+ # The total number of pages available if the results are paginated.
135
+ #
136
+ # @return [Integer]
137
+ #
138
+ def total_pages
139
+ @total_pages ||= (total_entries / per_page.to_f).ceil
140
+ end
141
+ # Compatibility with older versions of will_paginate
142
+ alias_method :page_count, :total_pages
143
+
144
+ # The total number of search results available.
145
+ #
146
+ # @return [Integer]
147
+ #
148
+ def total_entries
149
+ populate
150
+ @total_entries ||= @results[:total_found]
151
+ end
152
+
153
+ # The current page's offset, based on the number of records per page.
154
+ #
155
+ # @return [Integer]
156
+ #
157
+ def offset
158
+ (current_page - 1) * per_page
159
+ end
160
+
161
+ def each_with_groupby_and_count(&block)
162
+ populate
163
+ results[:matches].each_with_index do |match, index|
164
+ yield self[index],
165
+ match[:attributes]["@groupby"],
166
+ match[:attributes]["@count"]
405
167
  end
406
-
407
- def count(*args)
408
- results, client = search_results(*args.clone)
409
- results[:total_found] || 0
168
+ end
169
+
170
+ def each_with_weighting(&block)
171
+ populate
172
+ results[:matches].each_with_index do |match, index|
173
+ yield self[index], match[:weight]
410
174
  end
411
-
412
- # Checks if a document with the given id exists within a specific index.
413
- # Expected parameters:
414
- #
415
- # - ID of the document
416
- # - Index to check within
417
- # - Options hash (defaults to {})
418
- #
419
- # Example:
420
- #
421
- # ThinkingSphinx::Search.search_for_id(10, "user_core", :class => User)
422
- #
423
- def search_for_id(*args)
424
- options = args.extract_options!
425
- client = client_from_options options
426
-
427
- query, filters = search_conditions(
428
- options[:class], options[:conditions] || {}
429
- )
430
- client.filters += filters
431
- client.match_mode = :extended unless query.empty?
432
- client.id_range = args.first..args.first
433
-
434
- begin
435
- return client.query(query, args[1])[:matches].length > 0
436
- rescue Errno::ECONNREFUSED => err
437
- raise ThinkingSphinx::ConnectionError, "Connection to Sphinx Daemon (searchd) failed."
438
- end
175
+ end
176
+
177
+ def excerpt_for(string, model = nil)
178
+ if model.nil? && options[:classes].length == 1
179
+ model ||= options[:classes].first
439
180
  end
440
181
 
441
- private
182
+ populate
183
+ client.excerpts(
184
+ :docs => [string],
185
+ :words => results[:words].keys.join(' '),
186
+ :index => "#{model.sphinx_name}_core"
187
+ ).first
188
+ end
189
+
190
+ private
191
+
192
+ def config
193
+ ThinkingSphinx::Configuration.instance
194
+ end
195
+
196
+ def populate
197
+ return if @populated
198
+ @populated = true
442
199
 
443
- # This method handles the common search functionality, and returns both
444
- # the result hash and the client. Not super elegant, but it'll do for
445
- # the moment.
446
- #
447
- def search_results(*args)
448
- options = args.extract_options!
449
- query = args.join(' ')
450
- client = client_from_options options
451
-
452
- query = star_query(query, options[:star]) if options[:star]
453
-
454
- extra_query, filters = search_conditions(
455
- options[:class], options[:conditions] || {}
456
- )
457
- client.filters += filters
458
- client.match_mode = :extended unless extra_query.empty?
459
- query = [query, extra_query].join(' ')
460
- query.strip! # Because "" and " " are not equivalent
461
-
462
- set_sort_options! client, options
463
-
464
- client.limit = options[:per_page].to_i if options[:per_page]
465
- page = options[:page] ? options[:page].to_i : 1
466
- page = 1 if page <= 0
467
- client.offset = (page - 1) * client.limit
468
-
200
+ retry_on_stale_index do
469
201
  begin
470
- log "Sphinx: #{query}"
471
- results = client.query(query, '*', options[:comment] || '')
472
- log "Sphinx Result:"
473
- log results[:matches].collect { |m|
474
- m[:attributes]["sphinx_internal_id"]
475
- }.inspect
202
+ log "Querying Sphinx: #{query}"
203
+ @results = client.query query, index, comment
476
204
  rescue Errno::ECONNREFUSED => err
477
- raise ThinkingSphinx::ConnectionError, "Connection to Sphinx Daemon (searchd) failed."
205
+ raise ThinkingSphinx::ConnectionError,
206
+ 'Connection to Sphinx Daemon (searchd) failed.'
478
207
  end
479
-
480
- return results, client
481
- end
482
208
 
483
- # Set all the appropriate settings for the client, using the provided
484
- # options hash.
485
- #
486
- def client_from_options(options = {})
487
- config = ThinkingSphinx::Configuration.instance
488
- client = Riddle::Client.new config.address, config.port
489
- klass = options[:class]
490
- index_options = klass ? klass.sphinx_index_options : {}
491
-
492
- # The Riddle default is per-query max_matches=1000. If we set the
493
- # per-server max to a smaller value in sphinx.yml, we need to override
494
- # the Riddle default or else we get search errors like
495
- # "per-query max_matches=1000 out of bounds (per-server max_matches=200)"
496
- if per_server_max_matches = config.configuration.searchd.max_matches
497
- options[:max_matches] ||= per_server_max_matches
498
- end
499
-
500
- # Turn :index_weights => { "foo" => 2, User => 1 }
501
- # into :index_weights => { "foo" => 2, "user_core" => 1, "user_delta" => 1 }
502
- if iw = options[:index_weights]
503
- options[:index_weights] = iw.inject({}) do |hash, (index,weight)|
504
- if index.is_a?(Class)
505
- name = ThinkingSphinx::Index.name(index)
506
- hash["#{name}_core"] = weight
507
- hash["#{name}_delta"] = weight
508
- else
509
- hash[index] = weight
510
- end
511
- hash
512
- end
209
+ if options[:ids_only]
210
+ replace @results[:matches].collect { |match|
211
+ match[:attributes]["sphinx_internal_id"]
212
+ }
213
+ else
214
+ replace instances_from_matches
215
+ add_excerpter
513
216
  end
217
+ end
218
+ end
219
+
220
+ def add_excerpter
221
+ each do |object|
222
+ next if object.respond_to?(:excerpts)
514
223
 
515
- # Group by defaults using :group
516
- if options[:group]
517
- options[:group_by] = options[:group].to_s
518
- options[:group_function] ||= :attr
519
- end
224
+ excerpter = ThinkingSphinx::Excerpter.new self, object
225
+ block = lambda { excerpter }
520
226
 
521
- [
522
- :max_matches, :match_mode, :sort_mode, :sort_by, :id_range,
523
- :group_by, :group_function, :group_clause, :group_distinct, :cut_off,
524
- :retry_count, :retry_delay, :index_weights, :rank_mode,
525
- :max_query_time, :field_weights, :filters, :anchor, :limit
526
- ].each do |key|
527
- client.send(
528
- key.to_s.concat("=").to_sym,
529
- options[key] || index_options[key] || client.send(key)
530
- )
227
+ object.metaclass.instance_eval do
228
+ define_method(:excerpts, &block)
531
229
  end
532
-
533
- options[:classes] = [klass] if klass
534
-
535
- client.anchor = anchor_conditions(klass, options) || {} if client.anchor.empty?
536
-
537
- client.filters << Riddle::Client::Filter.new(
538
- "sphinx_deleted", [0]
539
- )
540
-
541
- # class filters
542
- client.filters << Riddle::Client::Filter.new(
543
- "class_crc", options[:classes].collect { |k| k.to_crc32s }.flatten
544
- ) if options[:classes]
545
-
546
- # normal attribute filters
547
- client.filters += options[:with].collect { |attr,val|
548
- Riddle::Client::Filter.new attr.to_s, filter_value(val)
549
- } if options[:with]
550
-
551
- # exclusive attribute filters
552
- client.filters += options[:without].collect { |attr,val|
553
- Riddle::Client::Filter.new attr.to_s, filter_value(val), true
554
- } if options[:without]
555
-
556
- # every-match attribute filters
557
- client.filters += options[:with_all].collect { |attr,vals|
558
- Array(vals).collect { |val|
559
- Riddle::Client::Filter.new attr.to_s, filter_value(val)
560
- }
561
- }.flatten if options[:with_all]
562
-
563
- # exclusive attribute filter on primary key
564
- client.filters += Array(options[:without_ids]).collect { |id|
565
- Riddle::Client::Filter.new 'sphinx_internal_id', filter_value(id), true
566
- } if options[:without_ids]
567
-
568
- client
569
230
  end
231
+ end
232
+
233
+ def self.log(message, method = :debug)
234
+ return if ::ActiveRecord::Base.logger.nil?
235
+ ::ActiveRecord::Base.logger.send method, message
236
+ end
237
+
238
+ def log(message, method = :debug)
239
+ self.class.log(message, method)
240
+ end
241
+
242
+ def client
243
+ client = config.client
244
+
245
+ index_options = (options[:classes] || []).length != 1 ?
246
+ {} : options[:classes].first.sphinx_indexes.first.local_options
247
+
248
+ [
249
+ :max_matches, :group_by, :group_function, :group_clause,
250
+ :group_distinct, :id_range, :cut_off, :retry_count, :retry_delay,
251
+ :rank_mode, :max_query_time, :field_weights
252
+ ].each do |key|
253
+ # puts "key: #{key}"
254
+ value = options[key] || index_options[key]
255
+ # puts "value: #{value.inspect}"
256
+ client.send("#{key}=", value) if value
257
+ end
258
+
259
+ client.limit = per_page
260
+ client.offset = offset
261
+ client.match_mode = match_mode
262
+ client.filters = filters
263
+ client.sort_mode = sort_mode
264
+ client.sort_by = sort_by
265
+ client.group_by = group_by if group_by
266
+ client.group_function = group_function if group_function
267
+ client.index_weights = index_weights
268
+ client.anchor = anchor
269
+
270
+ client
271
+ end
272
+
273
+ def retry_on_stale_index(&block)
274
+ stale_ids = []
275
+ retries = stale_retries
276
+
277
+ begin
278
+ options[:raise_on_stale] = retries > 0
279
+ block.call
280
+
281
+ # If ThinkingSphinx::Search#instances_from_matches found records in
282
+ # Sphinx but not in the DB and the :raise_on_stale option is set, this
283
+ # exception is raised. We retry a limited number of times, excluding the
284
+ # stale ids from the search.
285
+ rescue StaleIdsException => err
286
+ retries -= 1
287
+
288
+ # For logging
289
+ stale_ids |= err.ids
290
+ # ID exclusion
291
+ options[:without_ids] = Array(options[:without_ids]) | err.ids
292
+
293
+ log 'Sphinx Stale Ids (%s %s left): %s' % [
294
+ retries, (retries == 1 ? 'try' : 'tries'), stale_ids.join(', ')
295
+ ]
296
+ retry
297
+ end
298
+ end
299
+
300
+ def query
301
+ @query ||= begin
302
+ q = @args.join(' ') << conditions_as_query
303
+ (options[:star] ? star_query(q) : q).strip
304
+ end
305
+ end
306
+
307
+ def conditions_as_query
308
+ return '' if @options[:conditions].blank?
309
+
310
+ # Soon to be deprecated.
311
+ keys = @options[:conditions].keys.reject { |key|
312
+ attributes.include?(key)
313
+ }
570
314
 
571
- def star_query(query, custom_token = nil)
572
- token = custom_token.is_a?(Regexp) ? custom_token : /\w+/u
315
+ ' ' + keys.collect { |key|
316
+ "@#{key} #{options[:conditions][key]}"
317
+ }.join(' ')
318
+ end
319
+
320
+ def star_query(query)
321
+ token = options[:star].is_a?(Regexp) ? options[:star] : /\w+/u
573
322
 
574
- query.gsub(/("#{token}(.*?#{token})?"|(?![!-])#{token})/u) do
575
- pre, proper, post = $`, $&, $'
576
- is_operator = pre.match(%r{(\W|^)[@~/]\Z}) # E.g. "@foo", "/2", "~3", but not as part of a token
577
- is_quote = proper.starts_with?('"') && proper.ends_with?('"') # E.g. "foo bar", with quotes
578
- has_star = pre.ends_with?("*") || post.starts_with?("*")
579
- if is_operator || is_quote || has_star
580
- proper
581
- else
582
- "*#{proper}*"
583
- end
323
+ query.gsub(/("#{token}(.*?#{token})?"|(?![!-])#{token})/u) do
324
+ pre, proper, post = $`, $&, $'
325
+ # E.g. "@foo", "/2", "~3", but not as part of a token
326
+ is_operator = pre.match(%r{(\W|^)[@~/]\Z})
327
+ # E.g. "foo bar", with quotes
328
+ is_quote = proper.starts_with?('"') && proper.ends_with?('"')
329
+ has_star = pre.ends_with?("*") || post.starts_with?("*")
330
+ if is_operator || is_quote || has_star
331
+ proper
332
+ else
333
+ "*#{proper}*"
584
334
  end
585
335
  end
586
-
587
- def filter_value(value)
588
- case value
589
- when Range
590
- value.first.is_a?(Time) ? timestamp(value.first)..timestamp(value.last) : value
591
- when Array
592
- value.collect { |val| val.is_a?(Time) ? timestamp(val) : val }
336
+ end
337
+
338
+ def index
339
+ options[:index] || '*'
340
+ end
341
+
342
+ def comment
343
+ options[:comment] || ''
344
+ end
345
+
346
+ def match_mode
347
+ options[:match_mode] || (options[:conditions].blank? ? :all : :extended)
348
+ end
349
+
350
+ def sort_mode
351
+ @sort_mode ||= case options[:sort_mode]
352
+ when :asc
353
+ :attr_asc
354
+ when :desc
355
+ :attr_desc
356
+ when nil
357
+ case options[:order]
358
+ when String
359
+ :extended
360
+ when Symbol
361
+ :attr_asc
593
362
  else
594
- Array(value)
363
+ :relevance
595
364
  end
365
+ else
366
+ options[:sort_mode]
596
367
  end
597
-
598
- # Returns the integer timestamp for a Time object.
599
- #
600
- # If using Rails 2.1+, need to handle timezones to translate them back to
601
- # UTC, as that's what datetimes will be stored as by MySQL.
602
- #
603
- # in_time_zone is a method that was added for the timezone support in
604
- # Rails 2.1, which is why it's used for testing. I'm sure there's better
605
- # ways, but this does the job.
606
- #
607
- def timestamp(value)
608
- value.respond_to?(:in_time_zone) ? value.utc.to_i : value.to_i
368
+ end
369
+
370
+ def sort_by
371
+ case @sort_by = (options[:sort_by] || options[:order])
372
+ when String
373
+ sorted_fields_to_attributes(@sort_by)
374
+ when Symbol
375
+ field_names.include?(@sort_by) ?
376
+ @sort_by.to_s.concat('_sort') : @sort_by.to_s
377
+ else
378
+ ''
609
379
  end
380
+ end
381
+
382
+ def field_names
383
+ return [] if (options[:classes] || []).length != 1
610
384
 
611
- # Translate field and attribute conditions to the relevant search string
612
- # and filters.
613
- #
614
- def search_conditions(klass, conditions={})
615
- attributes = klass ? klass.sphinx_indexes.collect { |index|
616
- index.attributes.collect { |attrib| attrib.unique_name }
617
- }.flatten : []
618
-
619
- search_string = []
620
- filters = []
621
-
622
- conditions.each do |key,val|
623
- if attributes.include?(key.to_sym)
624
- filters << Riddle::Client::Filter.new(
625
- key.to_s, filter_value(val)
626
- )
627
- else
628
- search_string << "@#{key} #{val}"
629
- end
385
+ options[:classes].first.sphinx_indexes.collect { |index|
386
+ index.fields.collect { |field| field.unique_name }
387
+ }.flatten
388
+ end
389
+
390
+ def sorted_fields_to_attributes(order_string)
391
+ field_names.each { |field|
392
+ order_string.gsub!(/(^|\s)#{field}(,?\s|$)/) { |match|
393
+ match.gsub field.to_s, field.to_s.concat("_sort")
394
+ }
395
+ }
396
+
397
+ order_string
398
+ end
399
+
400
+ # Turn :index_weights => { "foo" => 2, User => 1 } into :index_weights =>
401
+ # { "foo" => 2, "user_core" => 1, "user_delta" => 1 }
402
+ #
403
+ def index_weights
404
+ weights = options[:index_weights] || {}
405
+ weights.keys.inject({}) do |hash, key|
406
+ if key.is_a?(Class)
407
+ name = ThinkingSphinx::Index.name(key)
408
+ hash["#{name}_core"] = weights[key]
409
+ hash["#{name}_delta"] = weights[key]
410
+ else
411
+ hash[key] = weights[key]
630
412
  end
631
413
 
632
- return search_string.join(' '), filters
414
+ hash
633
415
  end
416
+ end
417
+
418
+ def group_by
419
+ options[:group] ? options[:group].to_s : nil
420
+ end
421
+
422
+ def group_function
423
+ options[:group] ? :attr : nil
424
+ end
425
+
426
+ def internal_filters
427
+ filters = [Riddle::Client::Filter.new('sphinx_deleted', [0])]
634
428
 
635
- # Return the appropriate latitude and longitude values, depending on
636
- # whether the relevant attributes have been defined, and also whether
637
- # there's actually any values.
638
- #
639
- def anchor_conditions(klass, options)
640
- attributes = klass ? klass.sphinx_indexes.collect { |index|
641
- index.attributes.collect { |attrib| attrib.unique_name }
642
- }.flatten : []
643
-
644
- lat_attr = klass ? klass.sphinx_indexes.collect { |index|
645
- index.local_options[:latitude_attr]
646
- }.compact.first : nil
647
-
648
- lon_attr = klass ? klass.sphinx_indexes.collect { |index|
649
- index.local_options[:longitude_attr]
650
- }.compact.first : nil
651
-
652
- lat_attr = options[:latitude_attr] if options[:latitude_attr]
653
- lat_attr ||= :lat if attributes.include?(:lat)
654
- lat_attr ||= :latitude if attributes.include?(:latitude)
655
-
656
- lon_attr = options[:longitude_attr] if options[:longitude_attr]
657
- lon_attr ||= :lng if attributes.include?(:lng)
658
- lon_attr ||= :lon if attributes.include?(:lon)
659
- lon_attr ||= :long if attributes.include?(:long)
660
- lon_attr ||= :longitude if attributes.include?(:longitude)
661
-
662
- lat = options[:lat]
663
- lon = options[:lon]
664
-
665
- if options[:geo]
666
- lat = options[:geo].first
667
- lon = options[:geo].last
668
- end
669
-
670
- lat && lon ? {
671
- :latitude_attribute => lat_attr.to_s,
672
- :latitude => lat,
673
- :longitude_attribute => lon_attr.to_s,
674
- :longitude => lon
675
- } : nil
429
+ class_crcs = (options[:classes] || []).collect { |klass|
430
+ klass.to_crc32s
431
+ }.flatten
432
+
433
+ unless class_crcs.empty?
434
+ filters << Riddle::Client::Filter.new('class_crc', class_crcs)
676
435
  end
677
436
 
678
- # Set the sort options using the :order key as well as the appropriate
679
- # Riddle settings.
680
- #
681
- def set_sort_options!(client, options)
682
- klass = options[:class]
683
- fields = klass ? klass.sphinx_indexes.collect { |index|
684
- index.fields.collect { |field| field.unique_name }
685
- }.flatten : []
686
- index_options = klass ? klass.sphinx_index_options : {}
687
-
688
- order = options[:order] || index_options[:order]
689
- case order
690
- when Symbol
691
- client.sort_mode = :attr_asc if client.sort_mode == :relevance || client.sort_mode.nil?
692
- if fields.include?(order)
693
- client.sort_by = order.to_s.concat("_sort")
694
- else
695
- client.sort_by = order.to_s
696
- end
697
- when String
698
- client.sort_mode = :extended unless options[:sort_mode]
699
- client.sort_by = sorted_fields_to_attributes(order, fields)
437
+ filters << Riddle::Client::Filter.new(
438
+ 'sphinx_internal_id', filter_value(options[:without_ids]), true
439
+ ) if options[:without_ids]
440
+
441
+ filters
442
+ end
443
+
444
+ def condition_filters
445
+ (options[:conditions] || {}).collect { |attrib, value|
446
+ if attributes.include?(attrib)
447
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
700
448
  else
701
- # do nothing
449
+ nil
702
450
  end
703
-
704
- client.sort_mode = :attr_asc if client.sort_mode == :asc
705
- client.sort_mode = :attr_desc if client.sort_mode == :desc
451
+ }.compact
452
+ end
453
+
454
+ def filters
455
+ internal_filters +
456
+ condition_filters +
457
+ (options[:with] || {}).collect { |attrib, value|
458
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
459
+ } +
460
+ (options[:without] || {}).collect { |attrib, value|
461
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value), true
462
+ } +
463
+ (options[:with_all] || {}).collect { |attrib, values|
464
+ Array(values).collect { |value|
465
+ Riddle::Client::Filter.new attrib.to_s, filter_value(value)
466
+ }
467
+ }.flatten
468
+ end
469
+
470
+ # When passed a Time instance, returns the integer timestamp.
471
+ #
472
+ # If using Rails 2.1+, need to handle timezones to translate them back to
473
+ # UTC, as that's what datetimes will be stored as by MySQL.
474
+ #
475
+ # in_time_zone is a method that was added for the timezone support in
476
+ # Rails 2.1, which is why it's used for testing. I'm sure there's better
477
+ # ways, but this does the job.
478
+ #
479
+ def filter_value(value)
480
+ case value
481
+ when Range
482
+ filter_value(value.first).first..filter_value(value.last).first
483
+ when Array
484
+ value.collect { |v| filter_value(v) }.flatten
485
+ when Time
486
+ value.respond_to?(:in_time_zone) ? [value.utc.to_i] : [value.to_i]
487
+ else
488
+ Array(value)
706
489
  end
490
+ end
491
+
492
+ def anchor
493
+ return {} unless options[:geo] || (options[:lat] && options[:lng])
707
494
 
708
- # Search through a collection of fields and translate any appearances
709
- # of them in a string to their attribute equivalent for sorting.
710
- #
711
- def sorted_fields_to_attributes(string, fields)
712
- fields.each { |field|
713
- string.gsub!(/(^|\s)#{field}(,?\s|$)/) { |match|
714
- match.gsub field.to_s, field.to_s.concat("_sort")
715
- }
495
+ {
496
+ :latitude => options[:geo] ? options[:geo].first : options[:lat],
497
+ :longitude => options[:geo] ? options[:geo].last : options[:lng],
498
+ :latitude_attr => latitude_attr,
499
+ :longitude_attr => longitude_attr
500
+ }
501
+ end
502
+
503
+ def latitude_attr
504
+ options[:latitude_attr] ||
505
+ index_option(:latitude_attr) ||
506
+ attribute(:lat, :latitude)
507
+ end
508
+
509
+ def longitude_attr
510
+ options[:longitude_attr] ||
511
+ index_option(:longitude_attr) ||
512
+ attribute(:lon, :lng, :longitude)
513
+ end
514
+
515
+ def index_option(key)
516
+ return nil if options[:classes].length != 1
517
+
518
+ options[:classes].first.sphinx_indexes.collect { |index|
519
+ index.local_options[key]
520
+ }.compact.first
521
+ end
522
+
523
+ def attribute(*keys)
524
+ return nil if options[:classes].length != 1
525
+
526
+ keys.detect { |key|
527
+ attributes.include?(key)
528
+ }
529
+ end
530
+
531
+ def attributes
532
+ return [] if (options[:classes] || []).length != 1
533
+
534
+ attributes = options[:classes].first.sphinx_indexes.collect { |index|
535
+ index.attributes.collect { |attrib| attrib.unique_name }
536
+ }.flatten
537
+ end
538
+
539
+ def stale_retries
540
+ case options[:retry_stale]
541
+ when TrueClass
542
+ 3
543
+ when nil, FalseClass
544
+ 0
545
+ else
546
+ options[:retry_stale].to_i
547
+ end
548
+ end
549
+
550
+ def instances_from_class(klass, matches)
551
+ index_options = klass.sphinx_index_options
552
+
553
+ ids = matches.collect { |match| match[:attributes]["sphinx_internal_id"] }
554
+ instances = ids.length > 0 ? klass.find(
555
+ :all,
556
+ :joins => options[:joins],
557
+ :conditions => {klass.primary_key.to_sym => ids},
558
+ :include => (options[:include] || index_options[:include]),
559
+ :select => (options[:select] || index_options[:select]),
560
+ :order => (options[:sql_order] || index_options[:sql_order])
561
+ ) : []
562
+
563
+ # Raise an exception if we find records in Sphinx but not in the DB, so
564
+ # the search method can retry without them. See
565
+ # ThinkingSphinx::Search.retry_search_on_stale_index.
566
+ if options[:raise_on_stale] && instances.length < ids.length
567
+ stale_ids = ids - instances.map {|i| i.id }
568
+ raise StaleIdsException, stale_ids
569
+ end
570
+
571
+ # if the user has specified an SQL order, return the collection
572
+ # without rearranging it into the Sphinx order
573
+ return instances if options[:sql_order]
574
+
575
+ ids.collect { |obj_id|
576
+ instances.detect { |obj| obj.id == obj_id }
577
+ }
578
+ end
579
+
580
+ # Group results by class and call #find(:all) once for each group to reduce
581
+ # the number of #find's in multi-model searches.
582
+ #
583
+ def instances_from_matches
584
+ groups = results[:matches].group_by { |match|
585
+ match[:attributes]["class_crc"]
586
+ }
587
+ groups.each do |crc, group|
588
+ group.replace(
589
+ instances_from_class(class_from_crc(crc), group)
590
+ )
591
+ end
592
+
593
+ results[:matches].collect do |match|
594
+ groups.detect { |crc, group|
595
+ crc == match[:attributes]["class_crc"]
596
+ }[1].detect { |obj|
597
+ obj && obj.id == match[:attributes]["sphinx_internal_id"]
716
598
  }
717
-
718
- string
719
599
  end
600
+ end
601
+
602
+ def class_from_crc(crc)
603
+ @models_by_crc ||= begin
604
+ ThinkingSphinx.indexed_models.inject({}) do |hash, model|
605
+ hash[model.constantize.to_crc32] = model
606
+ Object.subclasses_of(model.constantize).each { |subclass|
607
+ hash[subclass.to_crc32] = subclass.name
608
+ }
609
+ hash
610
+ end
611
+ end
612
+ @models_by_crc[crc].constantize
613
+ end
614
+
615
+ def each_with_attribute(attribute, &block)
616
+ populate
617
+ results[:matches].each_with_index do |match, index|
618
+ yield self[index],
619
+ (match[:attributes][attribute] || match[:attributes]["@#{attribute}"])
620
+ end
621
+ end
622
+
623
+ def is_scope?(method)
624
+ options[:classes] && options[:classes].length == 1 &&
625
+ options[:classes].first.sphinx_scopes.include?(method)
626
+ end
627
+
628
+ def add_scope(method, *args, &block)
629
+ search = options[:classes].first.send(method, *args, &block)
720
630
 
721
- def log(message, method = :debug)
722
- return if ::ActiveRecord::Base.logger.nil?
723
- ::ActiveRecord::Base.logger.send method, message
631
+ search.options.keys.each do |key|
632
+ if HashOptions.include?(key)
633
+ options[key] ||= {}
634
+ options[key].merge! search.options[key]
635
+ elsif ArrayOptions.include?(key)
636
+ options[key] ||= []
637
+ options[key] += search.options[key]
638
+ else
639
+ options[key] = search.options[key]
640
+ end
724
641
  end
725
642
  end
726
643
  end