Class: SearchApi::Bridge::ActiveRecord

Inherits:
Base
  • Object
show all
Defined in:
lib/search_api/active_record_bridge.rb

Overview

SearchApi::Bridge::Base subclass that allows ActiveRecord to be used with SearchApi::Search::Base.

Constant Summary collapse

SINGLE_COLUMN_OPERATORS =

Operators that apply on a single column.

%w(eq neq lt lte gt gte contains starts_with ends_with)
MULTI_COLUMN_OPERATORS =

Operators that apply on several columns.

%w(full_text)
VALID_FIND_OPTIONS =
[ :conditions, :include, :joins, :order, :select, :group, :having ]

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(active_record_subclass) ⇒ ActiveRecord

store the active_record_subclass



25
26
27
# File 'lib/search_api/active_record_bridge.rb', line 25

def initialize(active_record_subclass) #:nodoc:
  @active_record_class = active_record_subclass
end

Class Method Details

.validate_find_options(options) ⇒ Object

:nodoc:



19
20
21
# File 'lib/search_api/active_record_bridge.rb', line 19

def validate_find_options(options) #:nodoc:
  options.assert_valid_keys(VALID_FIND_OPTIONS)
end

Instance Method Details

#automatic_search_attribute_builders(options) ⇒ Object

This method is called when a SearchApi::Search::Base’s model is set, in order to predefine some relevant search keys.

Returns an Array of SearchApi::Search::SearchAttributeBuilder instances.

Each builder can be used as an argument for SearchApi::Search::Base.search_accessor.

In the contexte of ActiveRecord:

  • each columns defines at least one search attribute, the obvious equality search attribute.

    With the same name as the column, it has the exact same behavior as the standard AR::Base.find(:all, :conditions => {column => value}).

  • each comparable column defines a lower and an upper-bound search attribute, named min_xxx and max_xxx when xxx is the column name.

Valid options are:

  • :type_cast - default false: when true, returned builders will use the :store_as option in order to type cast search attributes according to column type.

Example

class Search1 < SearchApi::Search::Base
  model Searchable
end

class Search2 < SearchApi::Search::Base
  model Searchable, :type_cast => true
end

search1 = Search1.new
search2 = Search2.new

search1.id = search2.id = '12'
search1.id => '12'       # no type cast
search2.id => 12         # type cast in action

search1.min_id = search2.min_id = '12'  # OK, predefined search attribute for numeric column
search1.max_id = search2.max_id = '12'  # OK, predefined search attribute for numeric column


73
74
75
76
77
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
105
106
# File 'lib/search_api/active_record_bridge.rb', line 73

def automatic_search_attribute_builders(options)

  # every column will create builders
  columns = @active_record_class.columns rescue []  # if no column can be found, there may be a database problem.
  
  builders = []
  columns.each do |column|
  
    # Append a builder for a standard AR::Base search.
    builders << ::SearchApi::Search::SearchAttributeBuilder.new(
                  column.name,                          # search attribute name is the column name,
                  :type_cast => options[:type_cast],    # type cast if required,
                  :column => column.name,               # look in to that very column...
                  :operator => :eq)                     # ... for equality
  
    # Create extra builders for comparable columns
    if column.klass < Comparable
      # Builder for a lower-bound search
      builders << ::SearchApi::Search::SearchAttributeBuilder.new(
                    "min_#{column.name}",               # search attribute name is min_column name,
                    :type_cast => options[:type_cast],  # type cast if required,
                    :column => column.name,             # look in to that very column...
                    :operator => :gte)                  # ... for values greater or equal to lower bound

      # Builder for a upper-bound search
      builders << ::SearchApi::Search::SearchAttributeBuilder.new(
                    "max_#{column.name}",               # search attribute name is max_column name,
                    :type_cast => options[:type_cast],  # type cast if required,
                    :column => column.name,             # look in to that very column...
                    :operator => :lte)                  # ... for values lower or equal to upper bound
    end
  end
  builders
end

#merge_find_options(options_array) ⇒ Object

Overrides default Bridge::Base.merge_find_options.

This methods returns a merge of options in options_array.



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
294
295
296
297
298
299
300
301
302
303
304
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
361
362
363
364
365
366
367
# File 'lib/search_api/active_record_bridge.rb', line 267

def merge_find_options(options_array)
  all_options = options_array.compact.inject({}) do |all_options, options|
    self.class.validate_find_options(options)
    options.each do |key, value|
      next if value.blank? || (value.respond_to?(:empty?) && value.empty?)
      (all_options[key] ||= []) << value
    end
    all_options
  end


  merged_options = {}


  # Merge :conditions options

  unless all_options[:conditions].nil? || all_options[:conditions].empty?
    # merge conditions with AND
    merged_options[:conditions] = '(' + all_options[:conditions].
                                          map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                          uniq.
                                          join(") AND (")+ ')'
  end


  # Merge :include options

  unless all_options[:include].nil? || all_options[:include].empty?
    # merge includes with set-union
    merged_options[:include] = all_options[:include].inject([]) { |merged_includes, include_options| merged_includes |= Array(include_options) }
  end


  # Merge :joins options

  unless all_options[:joins].nil? || all_options[:joins].empty?
    # merge joins with space
    merged_options[:joins] = all_options[:joins].
                                          map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                          uniq.
                                          join(' ')
  end


  # Merge :group and :having options

  unless all_options[:having].nil? || all_options[:having].empty?
    # default group by if having clause is present
    if all_options[:group].nil? || all_options[:group].empty?
      all_options[:group] = ["#{@active_record_class.table_name}.#{@active_record_class.primary_key}"]
    end
  end
  
  unless all_options[:group].nil? || all_options[:group].empty?
    # merge groups with comma
    merged_options[:group] = all_options[:group].
                                          map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                          uniq.
                                          join(', ')
  
    # merge having conditions into :group option
    unless all_options[:having].nil? || all_options[:having].empty?
      # merge having with AND
      merged_options[:group] += ' HAVING (' + all_options[:having].
                                              map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                              uniq.
                                              join(') AND (')+ ')'
    end
  end


  # Merge :order options

  unless all_options[:order].nil? || all_options[:order].empty?
    # merge order with comma
    merged_options[:order] = all_options[:order].
                                          map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                          join(', ')
  end


  # Merge :select options

  unless all_options[:select].nil? || all_options[:select].empty?
    # merge select with comma
    merged_options[:select] = all_options[:select].
                                          map { |fragment| SearchApi::SqlFragment.sanitize(fragment) }.
                                          uniq.
                                          join(', ')
  end

  if merged_options[:joins] && merged_options[:select].nil?
    # since joins add columns, restrict default column set to base class columns
    merged_options[:select] = "DISTINCT #{@active_record_class.table_name}.*"
  end


  # merged_options is now ready for ActiveRecord::Base

  merged_options
end

#rewrite_search_attribute_builder(search_attribute_builder) ⇒ Object

This method is called when a SearchApi::Search::Base.search_accessor is called, to help you implementing some usual ActiveRecord searches.

Modifies in place a SearchApi::Search::SearchAttributeBuilder.

On output, search_attribute_builder should be a valid SearchApi::Search::Base.add_search_attribute argument.

You may provide an :operator option.

Some apply on a single column, other on several ones.

Single-column operator are:

  • :eq - equality operator.

    It has the exact same behavior as the standard AR::Base.find(:all, :conditions => {column => value}).

  • :neq - inequality operator

  • :lt - “lower than” operator

  • :lte - “lower than or equal” operator

  • :gt - “greater than” operator

  • :gte - “greater than or equal” operator

  • :contains - uses LIKE sql operator

  • :starts_with - uses LIKE sql operator

  • :ends_with - uses LIKE sql operator

Multi-column operators are:

  • :full_text - full text search

Those operators require some other options:

  • :column - required by single column operator

  • :columns - required by multi column operator

  • :type_cast - optional for single column operators, default false. When true, search_attribute_builder is rewritten so that its :store_as option casts incoming values according to column type.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/search_api/active_record_bridge.rb', line 145

def rewrite_search_attribute_builder(search_attribute_builder)
  # consume :operator option
  operator = search_attribute_builder.options.delete(:operator)
  return unless operator

  if SINGLE_COLUMN_OPERATORS.include?(operator.to_s)

    search_attribute = search_attribute_builder.name
    options = search_attribute_builder.options

    # consume :column option
    column_name = options.delete(:column)
    raise ArgumentError.new("#{operator} operator requires the :column options to contain a column name.") unless column_name && !column_name.is_a?(Array)
  
    # we'll use that column name everywhere
    sql_column_name = "#{@active_record_class.table_name}.#{@active_record_class.connection.quote_column_name(column_name)}"
  
    # consume :type_cast option
    if options.delete(:type_cast)
      @active_record_instance ||= @active_record_class.new
      # §§§ what if :store_as option is already defined ?
      options[:store_as] = proc do |value|
        @active_record_instance.send("#{column_name}=", value)
        @active_record_instance.send(column_name)
      end
    end

    # block rewriting
    case operator
    when :eq
      search_attribute_builder.block = proc do |search|
        { :conditions => search.class.model.send(:sanitize_sql_hash, column_name => search.send(search_attribute)) }
      end

    when :neq
      # §§§ some work is necessary on boolean columns
      search_attribute_builder.block = proc do |search|
        case value = search.send(search_attribute)
        when nil
          { :conditions => "#{sql_column_name} IS NOT NULL" }
        else
          { :conditions => ["#{sql_column_name} <> ? OR #{sql_column_name} IS NULL", value] }
        end
      end

    when :lt
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute)
        { :conditions => ["#{sql_column_name} < ?", value] } unless value.nil?
      end

    when :lte
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute)
        { :conditions => ["#{sql_column_name} <= ?", value] } unless value.nil?
      end

    when :gt
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute)
        { :conditions => ["#{sql_column_name} > ?", value] } unless value.nil?
      end

    when :gte
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute)
        { :conditions => ["#{sql_column_name} >= ?", value] } unless value.nil?
      end

    when :contains
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute).to_s
        { :conditions => ["#{sql_column_name} LIKE ?", "%#{value}%"] } unless value.empty?
      end

    when :starts_with
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute).to_s
        { :conditions => ["#{sql_column_name} LIKE ?", "#{search.send(search_attribute)}%"] } unless value.empty?
      end

    when :ends_with
      search_attribute_builder.block = proc do |search|
        value = search.send(search_attribute).to_s
        { :conditions => ["#{sql_column_name} LIKE ?", "%#{search.send(search_attribute)}"] } unless value.empty?
      end
    end

  elsif MULTI_COLUMN_OPERATORS.include?(operator.to_s)

    search_attribute = search_attribute_builder.name
    options = search_attribute_builder.options

    # consume :columns || :column option
    column_names = Array(options.delete(:columns) || options.delete(:column))
    raise ArgumentError.new("#{operator} operator requires the :column or :columns options to contain column names.") if column_names.empty?
  
    # we'll use that column names everywhere
    sql_column_names = column_names.map do |column_name|
      "#{@active_record_class.table_name}.#{@active_record_class.connection.quote_column_name(column_name)}"
    end

    case operator
    when :full_text
      # We'll use TextCriterion class.
    
      # consume :exclude option
      exclude = options.delete(:exclude) || /^[^0-9].{0,2}$/

      search_attribute_builder.block = lambda do |search|
        value = search.send(search_attribute).to_s
        { :conditions => TextCriterion.new(value, :exclude => exclude).condition(sql_column_names) } unless value.empty?
      end
    end
  else
    raise ArgumentError.new("Unknown operator #{operator}")
  end
end