Module: SearchScope

Included in:
ActiveRecord::Base
Defined in:
lib/search_scope.rb

Defined Under Namespace

Classes: FilterScope, FilterScopeOption, SortScope

Instance Method Summary collapse

Instance Method Details

#active_filter_scopes_for(params, options = {}, &block) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/search_scope.rb', line 188

def active_filter_scopes_for(params, options={}, &block)
  active_filter_scopes = [] unless block
  filter_scopes_ordered.each do |filter_scope|
    selected_option = filter_scope.selected_option_for(params)
    if selected_option
      if block
        yield filter_scope, selected_option
      else 
        active_filter_scopes << [filter_scope, selected_option]
      end
    end
  end
  block ? nil : active_filter_scopes
end

#aggregate_named_scope(aggregate_scope, params) ⇒ Object

you supply an additional :named_scope. Just one for now. And it can’t take a param yet. (to enable this, we’d have to have something like :named_scope => [:scope_name, arg1, arg2]) at which point I’d rather just use filters TODO I may want to add the ability to pass a comma separated string.. but not yet



365
366
367
368
369
370
# File 'lib/search_scope.rb', line 365

def aggregate_named_scope(aggregate_scope, params)
  named_scope = params.delete :named_scope
  return aggregate_scope if named_scope.blank?
  raise "the :named_scope param currently only supports a single symbol and no args." unless named_scope.is_a? Symbol
  aggregate_scope = get_named_scope_from_object(aggregate_scope, named_scope)
end

#default_sort_choiceObject



158
159
160
# File 'lib/search_scope.rb', line 158

def default_sort_choice
  @default_sort_choice
end

#extract_filter_params(params) ⇒ Object



295
296
297
298
299
300
301
302
# File 'lib/search_scope.rb', line 295

def extract_filter_params(params)
  filter_params = {}
  params.keys.each do |key|
    key = key.to_s#.intern
    filter_params[key] = params.delete key if filter_scope_keys.include? key.intern
  end
  filter_params
end

#filter_scope(name, proc, options = {}, &block) ⇒ Object

filter scope blocks are not passed through to named_scope. Instead they are eval’d for filter_options.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/search_scope.rb', line 78

def filter_scope(name, proc, options={}, &block)
  proc, options = nil, proc if proc.is_a?(Hash)
  #default the search to a LIKE search if nothing is given
  label = options.delete(:label)
  #setup standard filters
  if proc
    #do nothing, it's custom
  elsif options.blank?
    proc = lambda { |term| { :conditions => ["#{table_name}.#{name} LIKE ?", "%#{term}%"] } }
  elsif options.is_a?(Hash) && options[:search_type]
    case options[:search_type]
    when :exact_match
      proc = lambda { |term| { :conditions => ["#{table_name}.#{name} = ?", term] } }
    else
      raise "unknown search_type for filter_scope: (#{name} - #{options[:search_type]})"
    end
  end
  raise "Already defined a filter_scope with name: #{name.inspect}" if filter_scope_keys.include? name
  @filter_scopes_ordered = nil
  
  scope = FilterScope.new name, label, :omit_from_ui => options.delete(:omit_from_ui), &block

  filter_scope_keys << scope.key
  filter_scopes[scope.key] = scope

  named_scope(scope.key, proc || options)
end

#filter_scope_keysObject



154
155
156
# File 'lib/search_scope.rb', line 154

def filter_scope_keys
  @filter_scope_keys ||= []
end

#filter_scopesObject



175
176
177
# File 'lib/search_scope.rb', line 175

def filter_scopes
  @filter_scopes ||= HashWithIndifferentAccess.new
end

#filter_scopes_orderedObject



179
180
181
182
183
184
185
186
# File 'lib/search_scope.rb', line 179

def filter_scopes_ordered
  return @filter_scopes_ordered if @filter_scopes_ordered
  @filter_scopes_ordered = []
  filter_scope_keys.each do |key|
    @filter_scopes_ordered << filter_scopes[key]
  end
  @filter_scopes_ordered
end

#get_named_scope_from_object(object, scope_name, *args) ⇒ Object



255
256
257
# File 'lib/search_scope.rb', line 255

def get_named_scope_from_object(object, scope_name, *args)
  scope = object.send(scope_name, *args)
end

#get_search_scope_from_object(object, scope_name, *args) ⇒ Object



259
260
261
262
# File 'lib/search_scope.rb', line 259

def get_search_scope_from_object(object, scope_name, *args)
  scope_name = "search_#{scope_name}".intern 
  get_named_scope_from_object object, scope_name, *args
end

#inactive_filter_scopes_for(params, options = {}, &block) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/search_scope.rb', line 203

def inactive_filter_scopes_for(params, options={}, &block)
  inactive_filter_scopes = [] unless block
  filter_scopes_ordered.each do |filter_scope|
    selected_option = filter_scope.selected_option_for(params)
    unless selected_option
      if block
        yield filter_scope
      else 
        inactive_filter_scopes << filter_scope
      end
    end
  end
  block ? nil : inactive_filter_scopes
end

#paginate_search(params = {}) ⇒ Object

this is for use with will_paginate



373
374
375
376
# File 'lib/search_scope.rb', line 373

def paginate_search(params={})
  params[:paginate] = true
  search params
end

#quick_search_scope_options(quick_search_terms, split_terms) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/search_scope.rb', line 264

def quick_search_scope_options(quick_search_terms, split_terms)
  conditions = []
  includes = []
  aggregate_scope = self
  
  if split_terms
    terms = quick_search_terms.split.compact
  else
    terms = [quick_search_terms]
  end
  terms.each_with_index do |term,index|
    term_conditions = []
    quick_search_scopes.each do |scope|
      # quick_search_scope = self.send(scope, term)
      quick_search_scope = get_search_scope_from_object(self, scope, term)
      query_options = quick_search_scope.proxy_options
      term_conditions << self.sanitize_sql_for_conditions(query_options[:conditions])
      #only do this once, the first time
      if query_options[:include] && index == 0
        includes << query_options[:include] unless includes.include? query_options[:include]
      end
      extra_options = query_options.keys - [:conditions, :include]
      raise "search_scope with quick_search does not support the #{extra_options.first.inspect} option at this time (#{scope.inspect})" if extra_options.first
    end
    conditions << term_conditions.collect{|c|"(#{c})"}.join(' OR ')     #ORing makes sure any of the terms exist somewhere in any of the fields. I think this is what we actually need, plus "relevance" (does that mean sphinx?)
    # conditions << term_conditions.collect{|c|"(#{c})"}.join(' AND ')  #ANDing this will make it so that all the terms MUST appear in one field, eg author first and last name
  end
  conditions_sql = conditions.collect{|c|"(#{c})"}.join(' AND ')
  {:conditions => conditions_sql, :include => includes}
end

#quick_search_scopesObject

for now, we’ll say that by definition, if a search_scope defines a lambda with one term, then it is a quick_search_scope



171
172
173
# File 'lib/search_scope.rb', line 171

def quick_search_scopes
  @quick_search_scopes ||= []
end

#search(params = {}) ⇒ Object

this searches by chaining all of the named_scopes (search_scopes) that were included in the params



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/search_scope.rb', line 305

def search(params={})
  params = params.clone
  #this lets you do a quick_search like: Book.search('foo')  instead of Book.search(:quick_search => 'foo')
  params = {:quick_search => params} unless params.kind_of? Hash
  params.reverse_merge! :split_terms => true
  paginate = params.delete :paginate
  aggregate_scope = self
  
  filter_params = extract_filter_params params
  
  search_scopes(params).each do |scope|
    if scope.is_a? Symbol
      # aggregate_scope = aggregate_scope.send(scope)
      aggregate_scope = get_search_scope_from_object(aggregate_scope, scope)
    elsif scope.is_a? Array
      # aggregate_scope = aggregate_scope.send(*scope)
      aggregate_scope = get_search_scope_from_object(aggregate_scope, *scope)
    else
      raise "unsupported type for search scope: #{scope.inspect}"
    end
  end

  filter_params.each do |filter_scope_key, filter_option_key|
    filter_scope_key = filter_scope_key.intern unless filter_scope_key.is_a? Symbol
    filter_option_key = filter_option_key.intern unless filter_option_key.blank? || filter_option_key.is_a?(Symbol)
    next if filter_option_key.blank?
    
    filter_scope = filter_scopes[filter_scope_key]
    filter_option = filter_scope.filter_options[filter_option_key]

    raise "search_scope: No filter option found with key: #{filter_option_key.inspect}" unless filter_option
    
    if filter_option.scope
      aggregate_scope = get_named_scope_from_object(aggregate_scope, filter_option.scope)
    else
      aggregate_scope = get_named_scope_from_object(aggregate_scope, filter_scope_key, filter_option.value)
    end
  end

  #TODO add filter_scopes here as well
  aggregate_scope = aggregate_named_scope(aggregate_scope, params)
  
  unless params[:quick_search].blank?
    aggregate_scope = aggregate_scope.scoped quick_search_scope_options(params[:quick_search], params[:split_terms])
  end

  params[:sort_by] ||= default_sort_choice
  if params[:sort_by]
    aggregate_scope = aggregate_scope.scoped sort_search_by_options(params[:sort_by], params[:sort_reverse])
  end
  if paginate
    aggregate_scope.paginate(:all, :page => params[:page])
  else
    aggregate_scope.find(:all)
  end
end

#search_scope(name, options = {}, &block) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/search_scope.rb', line 131

def search_scope(name, options={}, &block)
  #default the search to a LIKE search if nothing is given
  if options.blank?
    options = lambda { |term| { :conditions => ["#{table_name}.#{name} LIKE ?", "%#{term}%"] } }
  elsif options.is_a?(Hash) && options[:search_type]
    case options[:search_type]
    when :exact_match
      options = lambda { |term| { :conditions => ["#{table_name}.#{name} = ?", term] } }
    else
      raise "unknown search_type for search_scope: (#{name} - #{options[:search_type]})"
    end
  end
  search_scope_keys << name unless search_scope_keys.include? name
  if options.is_a? Proc
    quick_search_scopes << name if options.arity == 1
  end
  named_scope("search_#{name}".intern, options, &block)
end

#search_scope_keysObject



150
151
152
# File 'lib/search_scope.rb', line 150

def search_scope_keys
  @search_scope_keys ||= []
end

#search_scopes(options = {}) ⇒ Object



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/search_scope.rb', line 234

def search_scopes(options={})
  scopes = []
  options = options.clone
  split_terms = options.delete :split_terms
  search_scope_keys.each do |key|
    
    #if the scope key isn't in the params, don't include it
    next unless options[key]
    if split_terms
      # add the scope once for each term passed in (space delimited). this allows a search for 'star wars' to return only items where both terms match
      terms = options[key].split.compact
    else
      terms = [options[key]]
    end
    terms.each do |term|
      scopes << [key, term]
    end
  end
  scopes
end

#sort_choicesObject



162
163
164
# File 'lib/search_scope.rb', line 162

def sort_choices
  @sort_choices ||= []
end

#sort_choices_hashObject



166
167
168
# File 'lib/search_scope.rb', line 166

def sort_choices_hash
  @sort_choices_hash ||= HashWithIndifferentAccess.new
end

#sort_search_by(name, options = {}) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/search_scope.rb', line 106

def sort_search_by(name, options={})
  options[:label] ||= name.to_s.titleize
  if options[:order].blank?
    #this sets up the default order and reverse options. After the first one is set, the rest use the first sort option as sheir secondary order
    options[:order] = "#{table_name}.#{name.to_s}"
    options[:reverse] = "#{table_name}.#{name.to_s} DESC"
    if sort_choices.first
      options[:order] += ", #{sort_choices.first.order}"
      options[:reverse] += ", #{sort_choices.first.order}"
      #this gives you a comma separated string of includes if they exist
      options[:include_model] = [options[:include_model],sort_choices.first.include_model].compact.join ',' if sort_choices.first.include_model
    end
  end
  options[:order], options[:reverse] = options[:reverse], options[:order] if options[:reverse_orders]
  
  raise "you must supply a Symbol for a name to new_sortable_by (#{name.inspect})" unless name.is_a? Symbol
  return if sort_choices_hash.keys.include? name.to_s
  #the first sort added becomes the default sorting for all searches
  @default_sort_choice ||= name
  #TODO put this back and get rid of the return above once I figure out how to reset the class vars when the class is reloaded
  # raise "there is already a sortable defined for the name (#{name.inspect}" if sort_choices_hash.keys.include? name.to_s
  sort_choices_hash[name] = SortScope.new(name, options[:label], options[:order], options[:reverse], options[:include])
  sort_choices << sort_choices_hash[name]
end

#sort_search_by_options(sort_by, reverse = nil) ⇒ Object



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/search_scope.rb', line 218

def sort_search_by_options(sort_by, reverse=nil)
  if reverse.nil? || reverse.empty?
    reverse = false
  else
    reverse = ! %w{ false no f n }.include?(reverse)
  end
  choices_hash = sort_choices_hash[sort_by]
  return {} unless choices_hash
  raise "There is no :reverse option specified for sort_search_by #{sort_by.inspect} (#{name})" if reverse && choices_hash.reverse.blank?
  hash = { 
    :order    => reverse ? choices_hash.reverse : choices_hash.order, 
    :include  => choices_hash.include_model,
  }
  hash
end