scoped_search 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,24 +12,22 @@ module ScopedSearch
12
12
  # search_for named scope.
13
13
  #
14
14
  # This method will parse the query string and build an SQL query using the search
15
- # query. It will return an ampty hash if the search query is empty, in which case
15
+ # query. It will return an empty hash if the search query is empty, in which case
16
16
  # the scope call will simply return all records.
17
17
  def self.build_query(definition, *args)
18
- query = args[0]
18
+ query = args[0] ||=''
19
19
  options = args[1] || {}
20
-
20
+
21
21
  query_builder_class = self.class_for(definition)
22
22
  if query.kind_of?(ScopedSearch::QueryLanguage::AST::Node)
23
- return query_builder_class.new(definition, query, options[:profile]).build_find_params
23
+ return query_builder_class.new(definition, query, options[:profile]).build_find_params(options)
24
24
  elsif query.kind_of?(String)
25
- return query_builder_class.new(definition, ScopedSearch::QueryLanguage::Compiler.parse(query), options[:profile]).build_find_params
26
- elsif query.nil?
27
- return { }
25
+ return query_builder_class.new(definition, ScopedSearch::QueryLanguage::Compiler.parse(query), options[:profile]).build_find_params(options)
28
26
  else
29
27
  raise "Unsupported query object: #{query.inspect}!"
30
28
  end
31
29
  end
32
-
30
+
33
31
  # Loads the QueryBuilder class for the connection of the given definition.
34
32
  # If no specific adapter is found, the default QueryBuilder class is returned.
35
33
  def self.class_for(definition)
@@ -45,9 +43,10 @@ module ScopedSearch
45
43
 
46
44
  # Actually builds the find parameters hash that should be used in the search_for
47
45
  # named scope.
48
- def build_find_params
46
+ def build_find_params(options)
49
47
  parameters = []
50
48
  includes = []
49
+ joins = []
51
50
 
52
51
  # Build SQL WHERE clause using the AST
53
52
  sql = @ast.to_sql(self, definition) do |notification, value|
@@ -56,9 +55,19 @@ module ScopedSearch
56
55
  # Store the parameters, includes, etc so that they can be added to
57
56
  # the find-hash later on.
58
57
  case notification
59
- when :parameter then parameters << value
60
- when :include then includes << value
61
- else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
58
+ when :parameter then parameters << value
59
+ when :include then includes << value
60
+ when :joins then joins << value
61
+ else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
62
+ end
63
+ end
64
+ # Build SQL ORDER BY clause
65
+ order = order_by(options[:order]) do |notification, value|
66
+ case notification
67
+ when :parameter then parameters << value
68
+ when :include then includes << value
69
+ when :joins then joins << value
70
+ else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
62
71
  end
63
72
  end
64
73
 
@@ -66,18 +75,34 @@ module ScopedSearch
66
75
  find_attributes = {}
67
76
  find_attributes[:conditions] = [sql] + parameters unless sql.nil?
68
77
  find_attributes[:include] = includes.uniq unless includes.empty?
78
+ find_attributes[:joins] = joins.uniq unless joins.empty?
79
+ find_attributes[:order] = order unless order.nil?
80
+ find_attributes[:group] = options[:group] unless options[:group].nil?
81
+
69
82
  # p find_attributes # Uncomment for debugging
70
83
  return find_attributes
71
84
  end
72
85
 
86
+ def order_by(order, &block)
87
+ order ||= definition.default_order
88
+ if order
89
+ field = definition.field_by_name(order.to_s.split(' ')[0])
90
+ raise ScopedSearch::QueryNotSupported, "the field '#{order.to_s.split(' ')[0]}' in the order statement is not valid field for search" unless field
91
+ sql = field.to_sql(&block)
92
+ direction = (order.to_s.downcase.include?('desc')) ? " DESC" : " ASC"
93
+ order = sql + direction
94
+ end
95
+ return order
96
+ end
97
+
73
98
  # A hash that maps the operators of the query language with the corresponding SQL operator.
74
99
  SQL_OPERATORS = { :eq =>'=', :ne => '<>', :like => 'LIKE', :unlike => 'NOT LIKE',
75
100
  :gt => '>', :lt =>'<', :lte => '<=', :gte => '>=' }
76
-
101
+
77
102
  # Return the SQL operator to use given an operator symbol and field definition.
78
103
  #
79
104
  # By default, it will simply look up the correct SQL operator in the SQL_OPERATORS
80
- # hash, but this can be overrided by a database adapter.
105
+ # hash, but this can be overridden by a database adapter.
81
106
  def sql_operator(operator, field)
82
107
  SQL_OPERATORS[operator]
83
108
  end
@@ -96,20 +121,20 @@ module ScopedSearch
96
121
  def datetime_test(field, operator, value, &block) # :yields: finder_option_type, value
97
122
 
98
123
  # Parse the value as a date/time and ignore invalid timestamps
99
- timestamp = parse_temporal(value)
124
+ timestamp = definition.parse_temporal(value)
100
125
  return nil unless timestamp
101
- timestamp = Date.parse(timestamp.strftime('%Y-%m-%d')) if field.date?
102
126
 
127
+ timestamp = timestamp.to_date if field.date?
103
128
  # Check for the case that a date-only value is given as search keyword,
104
129
  # but the field is of datetime type. Change the comparison to return
105
130
  # more logical results.
106
- if timestamp.day_fraction == 0 && field.datetime?
107
-
131
+ if field.datetime?
132
+ span = (timestamp.day_fraction == 0) ? 1.day : 1.hour
108
133
  if [:eq, :ne].include?(operator)
109
134
  # Instead of looking for an exact (non-)match, look for dates that
110
135
  # fall inside/outside the range of timestamps of that day.
111
136
  yield(:parameter, timestamp)
112
- yield(:parameter, timestamp + 1)
137
+ yield(:parameter, timestamp + span)
113
138
  negate = (operator == :ne) ? 'NOT ' : ''
114
139
  field_sql = field.to_sql(operator, &block)
115
140
  return "#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)"
@@ -117,13 +142,13 @@ module ScopedSearch
117
142
  elsif operator == :gt
118
143
  # Make sure timestamps on the given date are not included in the results
119
144
  # by moving the date to the next day.
120
- timestamp += 1
145
+ timestamp += span
121
146
  operator = :gte
122
147
 
123
148
  elsif operator == :lte
124
149
  # Make sure the timestamps of the given date are included by moving the
125
150
  # date to the next date.
126
- timestamp += 1
151
+ timestamp += span
127
152
  operator = :lt
128
153
  end
129
154
  end
@@ -133,6 +158,26 @@ module ScopedSearch
133
158
  "#{field.to_sql(operator, &block)} #{sql_operator(operator, field)} ?"
134
159
  end
135
160
 
161
+ # Validate the key name is in the set and translate the value to the set value.
162
+ def set_test(field, operator,value, &block)
163
+ set_value = field.complete_value[value.to_sym]
164
+ raise ScopedSearch::QueryNotSupported, "'#{field.field}' should be one of '#{field.complete_value.keys.join(', ')}', but the query was '#{value}'" if set_value.nil?
165
+ raise ScopedSearch::QueryNotSupported, "Operator '#{operator}' not supported for '#{field.field}'" unless [:eq,:ne].include?(operator)
166
+ negate = ''
167
+ if [true,false].include?(set_value)
168
+ negate = 'NOT ' if operator == :ne
169
+ if field.numerical?
170
+ operator = (set_value == true) ? :gt : :eq
171
+ set_value = 0
172
+ else
173
+ operator = (set_value == true) ? :ne : :eq
174
+ set_value = false
175
+ end
176
+ end
177
+ yield(:parameter, set_value)
178
+ return "#{negate}(#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?)"
179
+ end
180
+
136
181
  # Generates a simple SQL test expression, for a field and value using an operator.
137
182
  #
138
183
  # This function needs a block that can be used to pass other information about the query
@@ -141,23 +186,26 @@ module ScopedSearch
141
186
  # <tt>field</tt>:: The field to test.
142
187
  # <tt>operator</tt>:: The operator used for comparison.
143
188
  # <tt>value</tt>:: The value to compare the field with.
144
- def sql_test(field, operator, value, &block) # :yields: finder_option_type, value
145
- if [:like, :unlike].include?(operator) && value !~ /^\%/ && value !~ /\%$/
146
- yield(:parameter, "%#{value}%")
189
+ def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
190
+ if field.key_field
191
+ yield(:parameter, lhs.sub(/^.*\./,''))
192
+ end
193
+ if field.ext_method
194
+ return field.to_ext_method_sql(lhs, sql_operator(operator, field), value, &block)
195
+ elsif [:like, :unlike].include?(operator)
196
+ yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%'))
147
197
  return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
148
198
  elsif field.temporal?
149
199
  return datetime_test(field, operator, value, &block)
200
+ elsif field.set?
201
+ return set_test(field, operator, value, &block)
150
202
  else
203
+ value = value.to_i if field.numerical?
151
204
  yield(:parameter, value)
152
205
  return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
153
206
  end
154
207
  end
155
208
 
156
- # Try to parse a string as a datetime.
157
- def parse_temporal(value)
158
- DateTime.parse(value, true) rescue nil
159
- end
160
-
161
209
  # This module gets included into the Field class to add SQL generation.
162
210
  module Field
163
211
 
@@ -166,12 +214,100 @@ module ScopedSearch
166
214
  # SQL query.
167
215
  #
168
216
  # This function may yield an :include that should be used in the
169
- # ActiveRecord::Base#find call, to make sure that the field is avalable
217
+ # ActiveRecord::Base#find call, to make sure that the field is available
170
218
  # for the SQL query.
171
219
  def to_sql(operator = nil, &block) # :yields: finder_option_type, value
172
- yield(:include, relation) if relation
173
- definition.klass.connection.quote_table_name(klass.table_name.to_s) + "." +
174
- definition.klass.connection.quote_column_name(field.to_s)
220
+ num = rand(1000000)
221
+ connection = klass.connection
222
+ if key_relation
223
+ yield(:joins, construct_join_sql(key_relation, num) )
224
+ klass_table_name = relation ? "#{klass.table_name}_#{num}" : connection.quote_table_name(klass.table_name)
225
+ return "#{key_klass.table_name}_#{num}.#{connection.quote_column_name(key_field.to_s)} = ? AND " +
226
+ "#{klass_table_name}.#{connection.quote_column_name(field.to_s)}"
227
+ elsif key_field
228
+ yield(:joins, construct_simple_join_sql(num))
229
+ klass_table_name = relation ? "#{klass.table_name}_#{num}" : connection.quote_table_name(klass.table_name)
230
+ return "#{key_klass.table_name}_#{num}.#{connection.quote_column_name(key_field.to_s)} = ? AND " +
231
+ "#{klass_table_name}.#{connection.quote_column_name(field.to_s)}"
232
+ elsif relation
233
+ yield(:include, relation)
234
+ end
235
+ column_name = connection.quote_table_name(klass.table_name.to_s) + "." + connection.quote_column_name(field.to_s)
236
+ column_name = "(#{column_name} >> #{offset*word_size} & #{2**word_size - 1})" if offset
237
+ column_name
238
+ end
239
+
240
+ # This method construct join statement for a key value table
241
+ # It assume the following table structure
242
+ # +----------+ +---------+ +--------+
243
+ # | main | | value | | key |
244
+ # | main_pk | | main_fk | | |
245
+ # | | | key_fk | | key_pk |
246
+ # +----------+ +---------+ +--------+
247
+ # uniq name for the joins are needed in case that there is more than one condition
248
+ # on different keys in the same query.
249
+ def construct_join_sql(key_relation, num )
250
+ join_sql = ""
251
+ connection = klass.connection
252
+ key = key_relation.to_s.singularize.to_sym
253
+ main = definition.klass.to_s.gsub(/.*::/,'').underscore.to_sym
254
+
255
+ key_table = klass.reflections[key].table_name
256
+ key_table_pk = klass.reflections[key].klass.primary_key
257
+
258
+ value_table = klass.table_name.to_s
259
+ value_table_fk_key = klass.reflections[key].association_foreign_key
260
+
261
+ if klass.reflections[main]
262
+ main_table = definition.klass.table_name
263
+ main_table_pk = klass.reflections[main].klass.primary_key
264
+ value_table_fk_main = klass.reflections[main].association_foreign_key
265
+
266
+ join_sql = "\n INNER JOIN #{connection.quote_table_name(value_table)} #{value_table}_#{num} ON (#{main_table}.#{main_table_pk} = #{value_table}_#{num}.#{value_table_fk_main})"
267
+ value_table = " #{value_table}_#{num}"
268
+ end
269
+ join_sql += "\n INNER JOIN #{connection.quote_table_name(key_table)} #{key_table}_#{num} ON (#{key_table}_#{num}.#{key_table_pk} = #{value_table}.#{value_table_fk_key}) "
270
+
271
+ return join_sql
272
+ end
273
+
274
+ # This method construct join statement for a key value table
275
+ # It assume the following table structure
276
+ # +----------+ +---------+
277
+ # | main | | key |
278
+ # | main_pk | | value |
279
+ # | | | main_fk |
280
+ # +----------+ +---------+
281
+ # uniq name for the joins are needed in case that there is more than one condition
282
+ # on different keys in the same query.
283
+ def construct_simple_join_sql( num )
284
+ connection = klass.connection
285
+ main = definition.klass.to_s.gsub(/.*::/,'').underscore.to_sym
286
+ key_value_table = klass.table_name
287
+
288
+ main_table = definition.klass.table_name
289
+ main_table_pk = klass.reflections[main].klass.primary_key
290
+ value_table_fk_main = klass.reflections[main].options[:foreign_key]
291
+ value_table_fk_main ||= klass.reflections[main].association_foreign_key
292
+
293
+ join_sql = "\n INNER JOIN #{connection.quote_table_name(key_value_table)} #{key_value_table}_#{num} ON (#{connection.quote_table_name(main_table)}.#{main_table_pk} = #{key_value_table}_#{num}.#{value_table_fk_main})"
294
+ return join_sql
295
+ end
296
+
297
+ def to_ext_method_sql(key, operator, value, &block)
298
+ raise ScopedSearch::QueryNotSupported, "'#{definition.klass}' doesn't respond to '#{ext_method}'" unless definition.klass.respond_to?(ext_method)
299
+ conditions = definition.klass.send(ext_method.to_sym,key, operator, value) rescue {}
300
+ raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' should return hash" unless conditions.kind_of?(Hash)
301
+ sql = ''
302
+ conditions.map do |notification, content|
303
+ case notification
304
+ when :include then yield(:include, content)
305
+ when :joins then yield(:joins, content)
306
+ when :conditions then sql = content
307
+ when :parameter then content.map{|c| yield(:parameter, c)}
308
+ end
309
+ end
310
+ return sql
175
311
  end
176
312
  end
177
313
 
@@ -181,9 +317,15 @@ module ScopedSearch
181
317
  # Defines the to_sql method for AST LeadNodes
182
318
  module LeafNode
183
319
  def to_sql(builder, definition, &block)
320
+ # for boolean fields allow a short format (example: for 'enabled = true' also allow 'enabled')
321
+ field = definition.field_by_name(value)
322
+ if field && field.set? && field.complete_value.values.include?(true)
323
+ key = field.complete_value.map{|k,v| k if v == true}.compact.first
324
+ return builder.set_test(field, :eq, key, &block)
325
+ end
184
326
  # Search keywords found without context, just search on all the default fields
185
327
  fragments = definition.default_fields_for(value).map do |field|
186
- builder.sql_test(field, field.default_operator, value, &block)
328
+ builder.sql_test(field, field.default_operator, value,'', &block)
187
329
  end
188
330
 
189
331
  case fragments.length
@@ -204,9 +346,12 @@ module ScopedSearch
204
346
 
205
347
  # Returns an IS (NOT) NULL SQL fragment
206
348
  def to_null_sql(builder, definition, &block)
207
- field = definition.fields[rhs.value.to_sym]
349
+ field = definition.field_by_name(rhs.value)
208
350
  raise ScopedSearch::QueryNotSupported, "Field '#{rhs.value}' not recognized for searching!" unless field
209
351
 
352
+ if field.key_field
353
+ yield(:parameter, rhs.value.to_s.sub(/^.*\./,''))
354
+ end
210
355
  case operator
211
356
  when :null then "#{field.to_sql(builder, &block)} IS NULL"
212
357
  when :notnull then "#{field.to_sql(builder, &block)} IS NOT NULL"
@@ -219,7 +364,7 @@ module ScopedSearch
219
364
 
220
365
  # Search keywords found without context, just search on all the default fields
221
366
  fragments = definition.default_fields_for(rhs.value, operator).map { |field|
222
- builder.sql_test(field, operator, rhs.value, &block) }.compact
367
+ builder.sql_test(field, operator, rhs.value,'', &block) }.compact
223
368
 
224
369
  case fragments.length
225
370
  when 0 then nil
@@ -234,9 +379,9 @@ module ScopedSearch
234
379
  raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
235
380
 
236
381
  # Search only on the given field.
237
- field = definition.fields[lhs.value.to_sym]
382
+ field = definition.field_by_name(lhs.value)
238
383
  raise ScopedSearch::QueryNotSupported, "Field '#{lhs.value}' not recognized for searching!" unless field
239
- builder.sql_test(field, operator, rhs.value, &block)
384
+ builder.sql_test(field, operator, rhs.value,lhs.value, &block)
240
385
  end
241
386
 
242
387
  # Convert this AST node to an SQL fragment.
@@ -268,7 +413,7 @@ module ScopedSearch
268
413
  # when using the (not) equals operator, regardless of the field's
269
414
  # collation setting.
270
415
  class MysqlAdapter < ScopedSearch::QueryBuilder
271
-
416
+
272
417
  # Patches the default <tt>sql_operator</tt> method to add
273
418
  # <tt>BINARY</tt> after the equals and not equals operator to force
274
419
  # case-sensitive comparisons.
@@ -281,11 +426,24 @@ module ScopedSearch
281
426
  end
282
427
  end
283
428
 
429
+ class Mysql2Adapter < ScopedSearch::QueryBuilder
430
+ # Patches the default <tt>sql_operator</tt> method to add
431
+ # <tt>BINARY</tt> after the equals and not equals operator to force
432
+ # case-sensitive comparisons.
433
+ def sql_operator(operator, field)
434
+ if [:ne, :eq].include?(operator) && field.textual?
435
+ "#{SQL_OPERATORS[operator]} BINARY"
436
+ else
437
+ super(operator, field)
438
+ end
439
+ end
440
+ end
441
+
284
442
  # The PostgreSQLAdapter make sure that searches are case sensitive when
285
443
  # using the like/unlike operators, by using the PostrgeSQL-specific
286
444
  # <tt>ILIKE operator</tt> instead of <tt>LIKE</tt>.
287
445
  class PostgreSQLAdapter < ScopedSearch::QueryBuilder
288
-
446
+
289
447
  # Switches out the default LIKE operator for ILIKE in the default
290
448
  # <tt>sql_operator</tt> method.
291
449
  def sql_operator(operator, field)
@@ -296,16 +454,22 @@ module ScopedSearch
296
454
  end
297
455
  end
298
456
  end
299
-
457
+
300
458
  # The Oracle adapter also requires some tweaks to make the case insensitive LIKE work.
301
459
  class OracleEnhancedAdapter < ScopedSearch::QueryBuilder
302
-
303
- def sql_test(field, operator, value, &block) # :yields: finder_option_type, value
460
+
461
+ def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
462
+ if field.key_field
463
+ yield(:parameter, lhs.sub(/^.*\./,''))
464
+ end
304
465
  if field.textual? && [:like, :unlike].include?(operator)
305
- yield(:parameter, (value !~ /^\%/ && value !~ /\%$/) ? "%#{value}%" : value)
466
+ yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.to_s.tr_s('%*', '%'))
306
467
  return "LOWER(#{field.to_sql(operator, &block)}) #{self.sql_operator(operator, field)} LOWER(?)"
468
+ elsif field.temporal?
469
+ return datetime_test(field, operator, value, &block)
307
470
  else
308
- return super(field, operator, value, &block)
471
+ yield(:parameter, value)
472
+ return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
309
473
  end
310
474
  end
311
475
  end
@@ -18,7 +18,9 @@ module ScopedSearch::QueryLanguage::Parser
18
18
  # Start the parsing process by parsing an expression sequence
19
19
  def parse
20
20
  @tokens = tokenize
21
- @tokens.delete_at(@tokens.size - 1) if @tokens.last.is_a?(Symbol)
21
+ while @tokens.last.is_a?(Symbol) do
22
+ @tokens.delete_at(@tokens.size - 1)
23
+ end
22
24
  parse_expression_sequence(true).simplify
23
25
  end
24
26
 
@@ -118,4 +120,4 @@ module ScopedSearch::QueryLanguage::Parser
118
120
  @current_token = @tokens.shift
119
121
  end
120
122
 
121
- end
123
+ end
@@ -3,7 +3,7 @@
3
3
  module ScopedSearch::QueryLanguage::Tokenizer
4
4
 
5
5
  # All keywords that the language supports
6
- KEYWORDS = { 'and' => :and, 'or' => :or, 'not' => :not, 'set?' => :notnull, 'null?' => :null }
6
+ KEYWORDS = { 'and' => :and, 'or' => :or, 'not' => :not, 'set?' => :notnull, 'has' => :notnull, 'null?' => :null, 'before' => :lt, 'after' => :gt, 'at' => :eq }
7
7
 
8
8
  # Every operator the language supports.
9
9
  OPERATORS = { '&' => :and, '|' => :or, '&&' => :and, '||' => :or, '-'=> :not, '!' => :not, '~' => :like, '!~' => :unlike,
@@ -59,7 +59,7 @@ module ScopedSearch::QueryLanguage::Tokenizer
59
59
  # reserved language keyword (the KEYWORDS array).
60
60
  def tokenize_keyword(&block)
61
61
  keyword = current_char
62
- keyword << next_char while /[^=<>\s\&\|\)\(,]/ =~ peek_char
62
+ keyword << next_char while /[^=~<>\s\&\|\)\(,]/ =~ peek_char
63
63
  KEYWORDS.has_key?(keyword.downcase) ? yield(KEYWORDS[keyword.downcase]) : yield(keyword)
64
64
  end
65
65
 
@@ -75,4 +75,4 @@ module ScopedSearch::QueryLanguage::Tokenizer
75
75
 
76
76
  alias :each :each_token
77
77
 
78
- end
78
+ end