DrMark-thinking-sphinx 0.9.7

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 (38) hide show
  1. data/LICENCE +20 -0
  2. data/README +52 -0
  3. data/lib/riddle.rb +22 -0
  4. data/lib/riddle/client.rb +593 -0
  5. data/lib/riddle/client/filter.rb +44 -0
  6. data/lib/riddle/client/message.rb +65 -0
  7. data/lib/riddle/client/response.rb +84 -0
  8. data/lib/test.rb +46 -0
  9. data/lib/thinking_sphinx.rb +82 -0
  10. data/lib/thinking_sphinx/active_record.rb +138 -0
  11. data/lib/thinking_sphinx/active_record/delta.rb +90 -0
  12. data/lib/thinking_sphinx/active_record/has_many_association.rb +29 -0
  13. data/lib/thinking_sphinx/active_record/search.rb +43 -0
  14. data/lib/thinking_sphinx/association.rb +140 -0
  15. data/lib/thinking_sphinx/attribute.rb +282 -0
  16. data/lib/thinking_sphinx/configuration.rb +277 -0
  17. data/lib/thinking_sphinx/field.rb +198 -0
  18. data/lib/thinking_sphinx/index.rb +334 -0
  19. data/lib/thinking_sphinx/index/builder.rb +212 -0
  20. data/lib/thinking_sphinx/index/faux_column.rb +97 -0
  21. data/lib/thinking_sphinx/rails_additions.rb +56 -0
  22. data/lib/thinking_sphinx/search.rb +455 -0
  23. data/spec/unit/thinking_sphinx/active_record/delta_spec.rb +185 -0
  24. data/spec/unit/thinking_sphinx/active_record/has_many_association_spec.rb +53 -0
  25. data/spec/unit/thinking_sphinx/active_record/search_spec.rb +81 -0
  26. data/spec/unit/thinking_sphinx/active_record_spec.rb +201 -0
  27. data/spec/unit/thinking_sphinx/association_spec.rb +247 -0
  28. data/spec/unit/thinking_sphinx/attribute_spec.rb +356 -0
  29. data/spec/unit/thinking_sphinx/configuration_spec.rb +476 -0
  30. data/spec/unit/thinking_sphinx/field_spec.rb +215 -0
  31. data/spec/unit/thinking_sphinx/index/builder_spec.rb +33 -0
  32. data/spec/unit/thinking_sphinx/index/faux_column_spec.rb +41 -0
  33. data/spec/unit/thinking_sphinx/index_spec.rb +230 -0
  34. data/spec/unit/thinking_sphinx/search_spec.rb +163 -0
  35. data/spec/unit/thinking_sphinx_spec.rb +107 -0
  36. data/tasks/thinking_sphinx_tasks.rake +1 -0
  37. data/tasks/thinking_sphinx_tasks.rb +86 -0
  38. metadata +90 -0
@@ -0,0 +1,97 @@
1
+ module ThinkingSphinx
2
+ class Index
3
+ # Instances of this class represent database columns and the stack of
4
+ # associations that lead from the base model to them.
5
+ #
6
+ # The name and stack are accessible through methods starting with __ to
7
+ # avoid conflicting with the method_missing calls that build the stack.
8
+ #
9
+ class FauxColumn
10
+ # Create a new column with a pre-defined stack. The top element in the
11
+ # stack will get shifted to be the name value.
12
+ #
13
+ def initialize(*stack)
14
+ @name = stack.pop
15
+ @stack = stack
16
+ end
17
+
18
+ # Can't use normal method name, as that could be an association or
19
+ # column name.
20
+ #
21
+ def __name
22
+ @name
23
+ end
24
+
25
+ # Can't use normal method name, as that could be an association or
26
+ # column name.
27
+ #
28
+ def __stack
29
+ @stack
30
+ end
31
+
32
+ # Returns true if the stack is empty *and* if the name is a string -
33
+ # which is an indication that of raw SQL, as opposed to a value from a
34
+ # table's column.
35
+ #
36
+ def is_string?
37
+ @name.is_a?(String) && @stack.empty?
38
+ end
39
+
40
+ # This handles any 'invalid' method calls and sets them as the name,
41
+ # and pushing the previous name into the stack. The object returns
42
+ # itself.
43
+ #
44
+ # If there's a single argument, it becomes the name, and the method
45
+ # symbol goes into the stack as well. Multiple arguments means new
46
+ # columns with the original stack and new names (from each argument) gets
47
+ # returned.
48
+ #
49
+ # Easier to explain with examples:
50
+ #
51
+ # col = FauxColumn.new :a, :b, :c
52
+ # col.__name #=> :c
53
+ # col.__stack #=> [:a, :b]
54
+ #
55
+ # col.whatever #=> col
56
+ # col.__name #=> :whatever
57
+ # col.__stack #=> [:a, :b, :c]
58
+ #
59
+ # col.something(:id) #=> col
60
+ # col.__name #=> :id
61
+ # col.__stack #=> [:a, :b, :c, :whatever, :something]
62
+ #
63
+ # cols = col.short(:x, :y, :z)
64
+ # cols[0].__name #=> :x
65
+ # cols[0].__stack #=> [:a, :b, :c, :whatever, :something, :short]
66
+ # cols[1].__name #=> :y
67
+ # cols[1].__stack #=> [:a, :b, :c, :whatever, :something, :short]
68
+ # cols[2].__name #=> :z
69
+ # cols[2].__stack #=> [:a, :b, :c, :whatever, :something, :short]
70
+ #
71
+ # Also, this allows method chaining to build up a relevant stack:
72
+ #
73
+ # col = FauxColumn.new :a, :b
74
+ # col.__name #=> :b
75
+ # col.__stack #=> [:a]
76
+ #
77
+ # col.one.two.three #=> col
78
+ # col.__name #=> :three
79
+ # col.__stack #=> [:a, :b, :one, :two]
80
+ #
81
+ def method_missing(method, *args)
82
+ @stack << @name
83
+ @name = method
84
+
85
+ if (args.empty?)
86
+ self
87
+ elsif (args.length == 1)
88
+ method_missing(args.first)
89
+ else
90
+ args.collect { |arg|
91
+ FauxColumn.new(@stack + [@name, arg])
92
+ }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,56 @@
1
+ module ThinkingSphinx
2
+ module HashExcept
3
+ # Returns a new hash without the given keys.
4
+ def except(*keys)
5
+ rejected = Set.new(respond_to?(:convert_key) ? keys.map { |key| convert_key(key) } : keys)
6
+ reject { |key,| rejected.include?(key) }
7
+ end
8
+
9
+ # Replaces the hash without only the given keys.
10
+ def except!(*keys)
11
+ replace(except(*keys))
12
+ end
13
+ end
14
+ end
15
+
16
+ Hash.send(
17
+ :include, ThinkingSphinx::HashExcept
18
+ ) unless Hash.instance_methods.include?("except")
19
+
20
+ module ThinkingSphinx
21
+ module ArrayExtractOptions
22
+ def extract_options!
23
+ last.is_a?(::Hash) ? pop : {}
24
+ end
25
+ end
26
+ end
27
+
28
+ Array.send(
29
+ :include, ThinkingSphinx::ArrayExtractOptions
30
+ ) unless Array.instance_methods.include?("extract_options!")
31
+
32
+ module ThinkingSphinx
33
+ module MysqlQuotedTableName
34
+ def quote_table_name(name) #:nodoc:
35
+ quote_column_name(name).gsub('.', '`.`')
36
+ end
37
+ end
38
+ end
39
+
40
+ if ActiveRecord::ConnectionAdapters.constants.include?("MysqlAdapter")
41
+ ActiveRecord::ConnectionAdapters::MysqlAdapter.send(
42
+ :include, ThinkingSphinx::MysqlQuotedTableName
43
+ ) unless ActiveRecord::ConnectionAdapters::MysqlAdapter.instance_methods.include?("quote_table_name")
44
+ end
45
+
46
+ module ThinkingSphinx
47
+ module ActiveRecordQuotedName
48
+ def quoted_table_name
49
+ self.connection.quote_table_name(self.table_name)
50
+ end
51
+ end
52
+ end
53
+
54
+ ActiveRecord::Base.extend(
55
+ ThinkingSphinx::ActiveRecordQuotedName
56
+ ) unless ActiveRecord::Base.respond_to?("quoted_table_name")
@@ -0,0 +1,455 @@
1
+ module ThinkingSphinx
2
+ # Once you've got those indexes in and built, this is the stuff that
3
+ # matters - how to search! This class provides a generic search
4
+ # interface - which you can use to search all your indexed models at once.
5
+ # Most times, you will just want a specific model's results - to search and
6
+ # search_for_ids methods will do the job in exactly the same manner when
7
+ # called from a model.
8
+ #
9
+ class Search
10
+ class << self
11
+ # Searches for results that match the parameters provided. Will only
12
+ # return the ids for the matching objects. See #search for syntax
13
+ # examples.
14
+ #
15
+ def search_for_ids(*args)
16
+ results, client = search_results(*args.clone)
17
+
18
+ options = args.extract_options!
19
+ page = options[:page] ? options[:page].to_i : 1
20
+
21
+ begin
22
+ pager = WillPaginate::Collection.new(page,
23
+ client.limit, results[:total] || 0)
24
+ pager.replace results[:matches].collect { |match| match[:doc] }
25
+ rescue
26
+ results[:matches].collect { |match| match[:doc] }
27
+ end
28
+ end
29
+
30
+ # Searches through the Sphinx indexes for relevant matches. There's
31
+ # various ways to search, sort, group and filter - which are covered
32
+ # below.
33
+ #
34
+ # Also, if you have WillPaginate installed, the search method can be used
35
+ # just like paginate. The same parameters - :page and :per_page - work as
36
+ # expected, and the returned result set can be used by the will_paginate
37
+ # helper.
38
+ #
39
+ # == Basic Searching
40
+ #
41
+ # The simplest way of searching is straight text.
42
+ #
43
+ # ThinkingSphinx::Search.search "pat"
44
+ # ThinkingSphinx::Search.search "google"
45
+ # User.search "pat", :page => (params[:page] || 1)
46
+ # Article.search "relevant news issue of the day"
47
+ #
48
+ # If you specify :include, like in an #find call, this will be respected
49
+ # when loading the relevant models from the search results.
50
+ #
51
+ # User.search "pat", :include => :posts
52
+ #
53
+ # == Searching by Fields
54
+ #
55
+ # If you want to step it up a level, you can limit your search terms to
56
+ # specific fields:
57
+ #
58
+ # User.search :conditions => {:name => "pat"}
59
+ #
60
+ # This uses Sphinx's extended match mode, unless you specify a different
61
+ # match mode explicitly (but then this way of searching won't work). Also
62
+ # note that you don't need to put in a search string.
63
+ #
64
+ # == Searching by Attributes
65
+ #
66
+ # Also known as filters, you can limit your searches to documents that
67
+ # have specific values for their attributes. There are two ways to do
68
+ # this. The first is one that works in all scenarios - using the :with
69
+ # option.
70
+ #
71
+ # ThinkingSphinx::Search.search :with => {:parent_id => 10}
72
+ #
73
+ # The second is only viable if you're searching with a specific model
74
+ # (not multi-model searching). With a single model, Thinking Sphinx
75
+ # can figure out what attributes and fields are available, so you can
76
+ # put it all in the :conditions hash, and it will sort it out.
77
+ #
78
+ # Node.search :conditions => {:parent_id => 10}
79
+ #
80
+ # Filters can be single values, arrays of values, or ranges.
81
+ #
82
+ # Article.search "East Timor", :conditions => {:rating => 3..5}
83
+ #
84
+ # == Excluding by Attributes
85
+ #
86
+ # Sphinx also supports negative filtering - where the filters are of
87
+ # attribute values to exclude. This is done with the :without option:
88
+ #
89
+ # User.search :without => {:role_id => 1}
90
+ #
91
+ # == Sorting
92
+ #
93
+ # Sphinx can only sort by attributes, so generally you will need to avoid
94
+ # using field names in your :order option. However, if you're searching
95
+ # on a single model, and have specified some fields as sortable, you can
96
+ # use those field names and Thinking Sphinx will interpret accordingly.
97
+ # Remember: this will only happen for single-model searches, and only
98
+ # through the :order option.
99
+ #
100
+ # Location.search "Melbourne", :order => :state
101
+ # User.search :conditions => {:role_id => 2}, :order => "name ASC"
102
+ #
103
+ # Keep in mind that if you use a string, you *must* specify the direction
104
+ # (ASC or DESC) else Sphinx won't return any results. If you use a symbol
105
+ # then Thinking Sphinx assumes ASC, but if you wish to state otherwise,
106
+ # use the :sort_mode option:
107
+ #
108
+ # Location.search "Melbourne", :order => :state, :sort_mode => :desc
109
+ #
110
+ # Of course, there are other sort modes - check out the Sphinx
111
+ # documentation[http://sphinxsearch.com/doc.html] for that level of
112
+ # detail though.
113
+ #
114
+ # == Grouping
115
+ #
116
+ # For this you can use the group_by, group_clause and group_function
117
+ # options - which are all directly linked to Sphinx's expectations. No
118
+ # magic from Thinking Sphinx. It can get a little tricky, so make sure
119
+ # you read all the relevant
120
+ # documentation[http://sphinxsearch.com/doc.html#clustering] first.
121
+ #
122
+ # Yes this section will be expanded, but this is a start.
123
+ #
124
+ # == Geo/Location Searching
125
+ #
126
+ # Sphinx - and therefore Thinking Sphinx - has the facility to search
127
+ # around a geographical point, using a given latitude and longitude. To
128
+ # take advantage of this, you will need to have both of those values in
129
+ # attributes. To search with that point, you can then use one of the
130
+ # following syntax examples:
131
+ #
132
+ # Address.search "Melbourne", :geo => [1.4, -2.217]
133
+ # Address.search "Australia", :geo => [-0.55, 3.108],
134
+ # :latitude_attr => "latit", :longitude_attr => "longit"
135
+ #
136
+ # The first example applies when your latitude and longitude attributes
137
+ # are named any of lat, latitude, lon, long or longitude. If that's not
138
+ # the case, you will need to explicitly state them in your search, _or_
139
+ # you can do so in your model:
140
+ #
141
+ # define_index do
142
+ # has :latit # Float column, stored in radians
143
+ # has :longit # Float column, stored in radians
144
+ #
145
+ # set_property :latitude_attr => "latit"
146
+ # set_property :longitude_attr => "longit"
147
+ # end
148
+ #
149
+ # Now, geo-location searching really only has an affect if you have a
150
+ # filter, sort or grouping clause related to it - otherwise it's just a
151
+ # normal search. To make use of the positioning difference, use the
152
+ # special attribute "@geodist" in any of your filters or sorting or grouping
153
+ # clauses.
154
+ #
155
+ # And don't forget - both the latitude and longitude you use in your
156
+ # search, and the values in your indexes, need to be stored as a float in radians,
157
+ # _not_ degrees. Keep in mind that if you do this conversion in SQL
158
+ # you will need to explicitly declare a column type of :float.
159
+ #
160
+ # define_index do
161
+ # has 'RADIANS(lat)', :as => :lat, :type => :float
162
+ # # ...
163
+ # end
164
+ #
165
+ def search(*args)
166
+ results, client = search_results(*args.clone)
167
+
168
+ ::ActiveRecord::Base.logger.error(
169
+ "Sphinx Error: #{results[:error]}"
170
+ ) if results[:error]
171
+
172
+ options = args.extract_options!
173
+ klass = options[:class]
174
+ page = options[:page] ? options[:page].to_i : 1
175
+
176
+ begin
177
+ pager = WillPaginate::Collection.new(page,
178
+ client.limit, results[:total] || 0)
179
+ pager.replace instances_from_results(results[:matches], options, klass)
180
+ rescue StandardError => err
181
+ instances_from_results(results[:matches], options, klass)
182
+ end
183
+ end
184
+
185
+ # Checks if a document with the given id exists within a specific index.
186
+ # Expected parameters:
187
+ #
188
+ # - ID of the document
189
+ # - Index to check within
190
+ # - Options hash (defaults to {})
191
+ #
192
+ # Example:
193
+ #
194
+ # ThinkingSphinx::Search.search_for_id(10, "user_core", :class => User)
195
+ #
196
+ def search_for_id(*args)
197
+ options = args.extract_options!
198
+ client = client_from_options options
199
+
200
+ query, filters = search_conditions(
201
+ options[:class], options[:conditions] || {}
202
+ )
203
+ client.filters += filters
204
+ client.match_mode = :extended unless query.empty?
205
+ client.id_range = args.first..args.first
206
+
207
+ begin
208
+ return client.query(query, args[1])[:matches].length > 0
209
+ rescue Errno::ECONNREFUSED => err
210
+ raise ThinkingSphinx::ConnectionError, "Connection to Sphinx Daemon (searchd) failed."
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # This method handles the common search functionality, and returns both
217
+ # the result hash and the client. Not super elegant, but it'll do for
218
+ # the moment.
219
+ #
220
+ def search_results(*args)
221
+ options = args.extract_options!
222
+ client = client_from_options options
223
+
224
+ query, filters = search_conditions(
225
+ options[:class], options[:conditions] || {}
226
+ )
227
+ client.filters += filters
228
+ client.match_mode = :extended unless query.empty?
229
+ query = args.join(" ") + query
230
+
231
+ set_sort_options! client, options
232
+
233
+ client.limit = options[:per_page].to_i if options[:per_page]
234
+ page = options[:page] ? options[:page].to_i : 1
235
+ client.offset = (page - 1) * client.limit
236
+
237
+ begin
238
+ ::ActiveRecord::Base.logger.debug "Sphinx: #{query}"
239
+ results = client.query query
240
+ ::ActiveRecord::Base.logger.debug "Sphinx Result: #{results[:matches].collect{|m| m[:doc]}.inspect}"
241
+ rescue Errno::ECONNREFUSED => err
242
+ raise ThinkingSphinx::ConnectionError, "Connection to Sphinx Daemon (searchd) failed."
243
+ end
244
+
245
+ return results, client
246
+ end
247
+
248
+ def instances_from_results(results, options = {}, klass = nil)
249
+ if klass.nil?
250
+ results.collect { |result| instance_from_result result, options }
251
+ else
252
+ ids = results.collect { |result| result[:doc] }
253
+ instances = klass.find(
254
+ :all,
255
+ :conditions => {klass.primary_key.to_sym => ids},
256
+ :include => options[:include],
257
+ :select => options[:select]
258
+ )
259
+ ids.collect { |obj_id| instances.detect { |obj| obj.id == obj_id } }
260
+ end
261
+ end
262
+
263
+ # Either use the provided class to instantiate a result from a model, or
264
+ # get the result's CRC value and determine the class from that.
265
+ #
266
+ def instance_from_result(result, options)
267
+ class_from_crc(result[:attributes]["class_crc"]).find(
268
+ result[:doc], :include => options[:include], :select => options[:select]
269
+ )
270
+ end
271
+
272
+ # Convert a CRC value to the corresponding class.
273
+ #
274
+ def class_from_crc(crc)
275
+ unless @models_by_crc
276
+ Configuration.new.load_models
277
+
278
+ @models_by_crc = ThinkingSphinx.indexed_models.inject({}) do |hash, model|
279
+ hash[model.constantize.to_crc32] = model
280
+ hash
281
+ end
282
+ end
283
+
284
+ @models_by_crc[crc].constantize
285
+ end
286
+
287
+ # Set all the appropriate settings for the client, using the provided
288
+ # options hash.
289
+ #
290
+ def client_from_options(options)
291
+ config = ThinkingSphinx::Configuration.new
292
+ client = Riddle::Client.new config.address, config.port
293
+ klass = options[:class]
294
+ index_options = klass ? klass.indexes.last.options : {}
295
+
296
+ [
297
+ :max_matches, :match_mode, :sort_mode, :sort_by, :id_range,
298
+ :group_by, :group_function, :group_clause, :group_distinct, :cut_off,
299
+ :retry_count, :retry_delay, :index_weights, :rank_mode,
300
+ :max_query_time, :field_weights, :filters, :anchor, :limit
301
+ ].each do |key|
302
+ client.send(
303
+ key.to_s.concat("=").to_sym,
304
+ options[key] || index_options[key] || client.send(key)
305
+ )
306
+ end
307
+
308
+ client.anchor = anchor_conditions(klass, options) || {} if client.anchor.empty?
309
+
310
+ client.filters << Riddle::Client::Filter.new(
311
+ "sphinx_deleted", [0]
312
+ )
313
+ # class filters
314
+ client.filters << Riddle::Client::Filter.new(
315
+ "class_crc", options[:classes].collect { |klass| klass.to_crc32 }
316
+ ) if options[:classes]
317
+
318
+ # normal attribute filters
319
+ client.filters += options[:with].collect { |attr,val|
320
+ Riddle::Client::Filter.new attr.to_s, filter_value(val)
321
+ } if options[:with]
322
+
323
+ # exclusive attribute filters
324
+ client.filters += options[:without].collect { |attr,val|
325
+ Riddle::Client::Filter.new attr.to_s, filter_value(val), true
326
+ } if options[:without]
327
+
328
+ client
329
+ end
330
+
331
+ def filter_value(value)
332
+ case value
333
+ when Range
334
+ value.first.is_a?(Time) ? value.first.to_i..value.last.to_i : value
335
+ when Array
336
+ value.collect { |val| val.is_a?(Time) ? val.to_i : val }
337
+ else
338
+ Array(value)
339
+ end
340
+ end
341
+
342
+ # Translate field and attribute conditions to the relevant search string
343
+ # and filters.
344
+ #
345
+ def search_conditions(klass, conditions={})
346
+ attributes = klass ? klass.indexes.collect { |index|
347
+ index.attributes.collect { |attrib| attrib.unique_name }
348
+ }.flatten : []
349
+
350
+ search_string = ""
351
+ filters = []
352
+
353
+ conditions.each do |key,val|
354
+ if attributes.include?(key.to_sym)
355
+ filters << Riddle::Client::Filter.new(
356
+ key.to_s,
357
+ val.is_a?(Range) ? val : Array(val)
358
+ )
359
+ else
360
+ search_string << "@#{key} #{val} "
361
+ end
362
+ end
363
+
364
+ filters << Riddle::Client::Filter.new(
365
+ "class_crc", [klass.to_crc32]
366
+ ) if klass
367
+
368
+ return search_string, filters
369
+ end
370
+
371
+ # Return the appropriate latitude and longitude values, depending on
372
+ # whether the relevant attributes have been defined, and also whether
373
+ # there's actually any values.
374
+ #
375
+ def anchor_conditions(klass, options)
376
+ attributes = klass ? klass.indexes.collect { |index|
377
+ index.attributes.collect { |attrib| attrib.unique_name }
378
+ }.flatten : []
379
+
380
+ lat_attr = klass ? klass.indexes.collect { |index|
381
+ index.options[:latitude_attr]
382
+ }.compact.first : nil
383
+
384
+ lon_attr = klass ? klass.indexes.collect { |index|
385
+ index.options[:longitude_attr]
386
+ }.compact.first : nil
387
+
388
+ lat_attr = options[:latitude_attr] if options[:latitude_attr]
389
+ lat_attr ||= :lat if attributes.include?(:lat)
390
+ lat_attr ||= :latitude if attributes.include?(:latitude)
391
+
392
+ lon_attr = options[:longitude_attr] if options[:longitude_attr]
393
+ lon_attr ||= :lon if attributes.include?(:lon)
394
+ lon_attr ||= :long if attributes.include?(:long)
395
+ lon_attr ||= :longitude if attributes.include?(:longitude)
396
+
397
+ lat = options[:lat]
398
+ lon = options[:lon]
399
+
400
+ if options[:geo]
401
+ lat = options[:geo].first
402
+ lon = options[:geo].last
403
+ end
404
+
405
+ lat && lon ? {
406
+ :latitude_attribute => lat_attr,
407
+ :latitude => lat,
408
+ :longitude_attribute => lon_attr,
409
+ :longitude => lon
410
+ } : nil
411
+ end
412
+
413
+ # Set the sort options using the :order key as well as the appropriate
414
+ # Riddle settings.
415
+ #
416
+ def set_sort_options!(client, options)
417
+ klass = options[:class]
418
+ fields = klass ? klass.indexes.collect { |index|
419
+ index.fields.collect { |field| field.unique_name }
420
+ }.flatten : []
421
+
422
+ case order = options[:order]
423
+ when Symbol
424
+ client.sort_mode ||= :attr_asc
425
+ if fields.include?(order)
426
+ client.sort_by = order.to_s.concat("_sort")
427
+ else
428
+ client.sort_by = order.to_s
429
+ end
430
+ when String
431
+ client.sort_mode = :extended
432
+ client.sort_by = sorted_fields_to_attributes(order, fields)
433
+ else
434
+ # do nothing
435
+ end
436
+
437
+ client.sort_mode = :attr_asc if client.sort_mode == :asc
438
+ client.sort_mode = :attr_desc if client.sort_mode == :desc
439
+ end
440
+
441
+ # Search through a collection of fields and translate any appearances
442
+ # of them in a string to their attribute equivalent for sorting.
443
+ #
444
+ def sorted_fields_to_attributes(string, fields)
445
+ fields.each { |field|
446
+ string.gsub!(/(^|\s)#{field}(,?\s|$)/) { |match|
447
+ match.gsub field.to_s, field.to_s.concat("_sort")
448
+ }
449
+ }
450
+
451
+ string
452
+ end
453
+ end
454
+ end
455
+ end