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.
- data/.gitignore +4 -1
- data/.infinity_test +8 -0
- data/Gemfile +15 -0
- data/lib/scoped_search/auto_complete_builder.rb +251 -0
- data/lib/scoped_search/definition.rb +90 -10
- data/lib/scoped_search/query_builder.rb +209 -45
- data/lib/scoped_search/query_language/parser.rb +4 -2
- data/lib/scoped_search/query_language/tokenizer.rb +3 -3
- data/lib/scoped_search/rails_helper.rb +210 -0
- data/lib/scoped_search.rb +9 -1
- data/scoped_search.gemspec +7 -5
- data/spec/database.yml +13 -6
- data/spec/integration/api_spec.rb +1 -1
- data/spec/integration/auto_complete_spec.rb +140 -0
- data/spec/integration/key_value_querying_spec.rb +87 -0
- data/spec/integration/ordinal_querying_spec.rb +105 -10
- data/spec/integration/profile_querying_spec.rb +1 -1
- data/spec/integration/relation_querying_spec.rb +186 -194
- data/spec/integration/set_query_spec.rb +76 -0
- data/spec/integration/string_querying_spec.rb +19 -2
- data/spec/lib/matchers.rb +10 -0
- data/spec/spec_helper.rb +2 -4
- data/spec/unit/ast_spec.rb +1 -1
- data/spec/unit/auto_complete_builder_spec.rb +20 -0
- data/spec/unit/definition_spec.rb +1 -1
- data/spec/unit/parser_spec.rb +1 -1
- data/spec/unit/query_builder_spec.rb +2 -1
- data/spec/unit/tokenizer_spec.rb +1 -1
- data/tasks/github-gem.rake +18 -14
- metadata +33 -7
@@ -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
|
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
|
-
|
60
|
-
|
61
|
-
|
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
|
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
|
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 +
|
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 +=
|
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 +=
|
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
|
146
|
-
yield(:parameter,
|
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
|
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
|
-
|
173
|
-
|
174
|
-
|
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.
|
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.
|
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 !~
|
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
|
-
|
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
|
-
|
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 /[
|
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
|