scoped_search 2.2.1 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -6,4 +6,7 @@ scoped_search-*.gem
6
6
  /coverage
7
7
  /classes
8
8
  /files
9
- /spec/database.yml
9
+ /spec/database.yml
10
+ .bundle/
11
+ Gemfile.lock
12
+ .idea/
data/.infinity_test ADDED
@@ -0,0 +1,8 @@
1
+ infinity_test do
2
+
3
+ use :rubies => %w(1.8.7 1.9.2 ree jruby rbx), :test_framework => :rspec
4
+
5
+ before(:each_ruby) do |environment|
6
+ environment.system('bundle install')
7
+ end
8
+ end
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source :rubygems
2
+ gemspec
3
+
4
+ # ScopedSearch is always tested on a sqlite3 memory database.
5
+ # Uncomment the following gems to test scoped search on other RDBMSs.
6
+ # You can specify the databasde connection details in spec/database.yml
7
+
8
+ gem 'mysql2', '~> 0.2.6' # preferred over 'mysql'
9
+ #gem 'mysql'
10
+ # gem 'postgresql'
11
+
12
+ # # JDBC connections
13
+ # gem 'activerecord-jdbcsqlite3-adapter'
14
+ # gem 'activerecord-jdbcmysql-adapter'
15
+ # gem 'activerecord-jdbcpostgresql-adapter'
@@ -0,0 +1,251 @@
1
+ module ScopedSearch
2
+
3
+
4
+ LOGICAL_INFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_INFIX_OPERATORS
5
+ LOGICAL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_PREFIX_OPERATORS
6
+ NULL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::NULL_PREFIX_OPERATORS
7
+ NULL_PREFIX_COMPLETER = ['has']
8
+ COMPARISON_OPERATORS = ScopedSearch::QueryLanguage::Parser::COMPARISON_OPERATORS
9
+ PREFIX_OPERATORS = LOGICAL_PREFIX_OPERATORS + NULL_PREFIX_OPERATORS
10
+
11
+ # The AutoCompleteBuilder class builds suggestions to complete query based on
12
+ # the query language syntax.
13
+ class AutoCompleteBuilder
14
+
15
+ attr_reader :ast, :definition, :query, :tokens
16
+
17
+ # This method will parse the query string and build suggestion list using the
18
+ # search query.
19
+ def self.auto_complete(definition, query)
20
+ return [] if (query.nil? or definition.nil? or !definition.respond_to?(:fields))
21
+
22
+ new(definition, query).build_autocomplete_options
23
+ end
24
+
25
+ # Initializes the instance by setting the relevant parameters
26
+ def initialize(definition, query)
27
+ @definition = definition
28
+ @ast = ScopedSearch::QueryLanguage::Compiler.parse(query)
29
+ @query = query
30
+ @tokens = tokenize
31
+ end
32
+
33
+ # Test the validity of the current query and suggest possible completion
34
+ def build_autocomplete_options
35
+ # First parse to find illegal syntax in the existing query,
36
+ # this method will throw exception on bad syntax.
37
+ is_query_valid
38
+
39
+ # get the completion options
40
+ node = last_node
41
+ completion = complete_options(node)
42
+
43
+ suggestions = []
44
+ suggestions += complete_keyword if completion.include?(:keyword)
45
+ suggestions += LOGICAL_INFIX_OPERATORS if completion.include?(:logical_op)
46
+ suggestions += LOGICAL_PREFIX_OPERATORS + NULL_PREFIX_COMPLETER if completion.include?(:prefix_op)
47
+ suggestions += complete_operator(node) if completion.include?(:infix_op)
48
+ suggestions += complete_value if completion.include?(:value)
49
+
50
+ build_suggestions(suggestions, completion.include?(:value))
51
+ end
52
+
53
+ # parse the query and return the complete options
54
+ def complete_options(node)
55
+
56
+ return [:keyword] + [:prefix_op] if tokens.empty?
57
+
58
+ #prefix operator
59
+ return [:keyword] if last_token_is(PREFIX_OPERATORS)
60
+
61
+ # left hand
62
+ if is_left_hand(node)
63
+ if (tokens.size == 1 || last_token_is(PREFIX_OPERATORS + LOGICAL_INFIX_OPERATORS) ||
64
+ last_token_is(PREFIX_OPERATORS + LOGICAL_INFIX_OPERATORS, 2))
65
+ options = [:keyword]
66
+ options += [:prefix_op] unless last_token_is(PREFIX_OPERATORS)
67
+ else
68
+ options = [:logical_op]
69
+ end
70
+ return options
71
+ end
72
+
73
+ if is_right_hand
74
+ # right hand
75
+ return [:value]
76
+ else
77
+ # comparison operator completer
78
+ return [:infix_op]
79
+ end
80
+ end
81
+
82
+ # Test the validity of the existing query, this method will throw exception on illegal
83
+ # query syntax.
84
+ def is_query_valid
85
+ # skip test for null prefix operators if in the process of completing the field name.
86
+ return if(last_token_is(NULL_PREFIX_OPERATORS, 2) && !(query =~ /(\s|\)|,)$/))
87
+ QueryBuilder.build_query(definition, query)
88
+ end
89
+
90
+ def is_left_hand(node)
91
+ field = definition.field_by_name(node.value)
92
+ lh = field.nil? || field.key_field && !(query.end_with?(' '))
93
+ lh = lh || last_token_is(NULL_PREFIX_OPERATORS, 2)
94
+ lh = lh && !is_right_hand
95
+ lh
96
+ end
97
+
98
+ def is_right_hand
99
+ rh = last_token_is(COMPARISON_OPERATORS)
100
+ if(tokens.size > 1 && !(query.end_with?(' ')))
101
+ rh = rh || last_token_is(COMPARISON_OPERATORS, 2)
102
+ end
103
+ rh
104
+ end
105
+
106
+ def last_node
107
+ last = ast
108
+ while (last.kind_of?(ScopedSearch::QueryLanguage::AST::OperatorNode) && !(last.children.empty?)) do
109
+ last = last.children.last
110
+ end
111
+ last
112
+ end
113
+
114
+ def last_token_is(list,index = 1)
115
+ if tokens.size >= index
116
+ return list.include?(tokens[tokens.size - index])
117
+ end
118
+ return false
119
+ end
120
+
121
+ def tokenize
122
+ tokens = ScopedSearch::QueryLanguage::Compiler.tokenize(query)
123
+ # skip parenthesis, it is not needed for the auto completer.
124
+ tokens.delete_if {|t| t == :lparen || t == :rparen }
125
+ tokens
126
+ end
127
+
128
+ def build_suggestions(suggestions, is_value)
129
+ return [] if (suggestions.blank?)
130
+
131
+ q=query
132
+ unless q =~ /(\s|\)|,)$/
133
+ val = Regexp.escape(tokens.last.to_s).gsub('\*', '.*')
134
+ suggestions = suggestions.map {|s| s if s.to_s =~ /^#{val}/i}.compact
135
+ q.chomp!(tokens.last.to_s)
136
+ end
137
+
138
+ # for doted field names compact the suggestions list to be one suggestion
139
+ # unless the user has typed the relation name entirely or the suggestion list
140
+ # is short.
141
+ if (suggestions.size > 10 && (tokens.empty? || !(tokens.last.to_s.include?('.')) ) && !(is_value))
142
+ suggestions = suggestions.map {|s|
143
+ (s.to_s.split('.')[0].end_with?(tokens.last)) ? s.to_s : s.to_s.split('.')[0]
144
+ }
145
+ end
146
+
147
+ suggestions.uniq.map {|m| "#{q} #{m}".gsub(/\s+/," ")}
148
+ end
149
+
150
+ # suggest all searchable field names.
151
+ # in relations suggest only the long format relation.field.
152
+ def complete_keyword
153
+ keywords = []
154
+ definition.fields.each do|f|
155
+ if (f[1].key_field)
156
+ keywords += complete_key(f[0], f[1], tokens.last)
157
+ else
158
+ keywords << f[0].to_s+' '
159
+ end
160
+ end
161
+ keywords.sort
162
+ end
163
+
164
+ #this method completes the keys list in a key-value schema in the format table.keyName
165
+ def complete_key(name, field, val)
166
+ return ["#{name}."] if !val || !val.is_a?(String) || !(val.include?('.'))
167
+ val = val.sub(/.*\./,'')
168
+
169
+ field_name = field.key_field
170
+ opts = value_conditions(field.key_field, val).merge(:limit => 20, :select => field_name, :group => field_name )
171
+
172
+ field.key_klass.all(opts).map(&field_name).compact.map{ |f| "#{name}.#{f} "}
173
+ end
174
+
175
+ # this method auto-completes values of fields that have a :complete_value marker
176
+ def complete_value
177
+ if last_token_is(COMPARISON_OPERATORS)
178
+ token = tokens[tokens.size-2]
179
+ val = ''
180
+ else
181
+ token = tokens[tokens.size-3]
182
+ val = tokens[tokens.size-1]
183
+ end
184
+
185
+ field = definition.field_by_name(token)
186
+ return [] unless field && field.complete_value
187
+
188
+ return complete_set(field) if field.set?
189
+ return complete_date_value if field.temporal?
190
+ return complete_key_value(field, token, val) if field.key_field
191
+
192
+ opts = value_conditions(field.field, val)
193
+ opts.merge!(:limit => 20, :select => "DISTINCT #{field.field}")
194
+ return field.klass.all(opts).map(&field.field).compact.map{|v| v.to_s =~ /\s+/ ? "\"#{v}\"" : v}
195
+ end
196
+
197
+ # set value completer
198
+ def complete_set(field)
199
+ field.complete_value.keys
200
+ end
201
+ # date value completer
202
+ def complete_date_value
203
+ options =[]
204
+ options << '"30 minutes ago"'
205
+ options << '"1 hour ago"'
206
+ options << '"2 hours ago"'
207
+ options << 'Today'
208
+ options << 'Yesterday'
209
+ options << 2.days.ago.strftime('%A')
210
+ options << 3.days.ago.strftime('%A')
211
+ options << 4.days.ago.strftime('%A')
212
+ options << 5.days.ago.strftime('%A')
213
+ options << '"6 days ago"'
214
+ options << 7.days.ago.strftime('"%b %d,%Y"')
215
+ options
216
+ end
217
+
218
+ # complete values in a key-value schema
219
+ def complete_key_value(field, token, val)
220
+ key_name = token.sub(/^.*\./,"")
221
+ key_opts = value_conditions(field.field,val).merge(:conditions => {field.key_field => key_name})
222
+ key_klass = field.key_klass.first(key_opts)
223
+ raise ScopedSearch::QueryNotSupported, "Field '#{key_name}' not recognized for searching!" if key_klass.nil?
224
+
225
+ opts = {:select => "DISTINCT #{field.field}"}
226
+ if(field.key_klass != field.klass)
227
+ key = field.key_klass.to_s.gsub(/.*::/,'').underscore.to_sym
228
+ fk = field.klass.reflections[key].association_foreign_key.to_sym
229
+ opts.merge!(:conditions => {fk => key_klass.id})
230
+ else
231
+ opts.merge!(key_opts)
232
+ end
233
+ return field.klass.all(opts.merge(:limit => 20)).map(&field.field)
234
+ end
235
+
236
+ #this method returns conditions for selecting completion from partial value
237
+ def value_conditions(field_name, val)
238
+ return val.blank? ? {} : {:conditions => "#{field_name} LIKE '#{val}%'".tr_s('%*', '%')}
239
+ end
240
+
241
+ # This method complete infix operators by field type
242
+ def complete_operator(node)
243
+ definition.operator_by_field_name(node.value)
244
+ end
245
+
246
+ end
247
+
248
+ end
249
+
250
+ # Load lib files
251
+ require 'scoped_search/query_builder'
@@ -15,11 +15,24 @@ module ScopedSearch
15
15
  # class, so you should not create instances of this class yourself.
16
16
  class Field
17
17
 
18
- attr_reader :definition, :field, :only_explicit, :relation
18
+ attr_reader :definition, :field, :only_explicit, :relation, :key_relation,
19
+ :key_field, :complete_value, :offset, :word_size, :ext_method, :operators
19
20
 
20
21
  # The ActiveRecord-based class that belongs to this field.
21
22
  def klass
22
23
  if relation
24
+ related = definition.klass.reflections[relation]
25
+ raise ScopedSearch::QueryNotSupported, "relation '#{relation}' not one of #{definition.klass.reflections.keys.join(', ')} " if related.nil?
26
+ related.klass
27
+ else
28
+ definition.klass
29
+ end
30
+ end
31
+ # The ActiveRecord-based class that belongs the key field in a key-value pair.
32
+ def key_klass
33
+ if key_relation
34
+ definition.klass.reflections[key_relation].klass
35
+ elsif relation
23
36
  definition.klass.reflections[relation].klass
24
37
  else
25
38
  definition.klass
@@ -62,6 +75,11 @@ module ScopedSearch
62
75
  [:string, :text].include?(type)
63
76
  end
64
77
 
78
+ # Returns true if this is a set.
79
+ def set?
80
+ complete_value.is_a?(Hash)
81
+ end
82
+
65
83
  # Returns the default search operator for this field.
66
84
  def default_operator
67
85
  @default_operator ||= case type
@@ -70,12 +88,20 @@ module ScopedSearch
70
88
  end
71
89
  end
72
90
 
91
+ def default_order(options)
92
+ return nil if options[:default_order].nil?
93
+ field_name = options[:on] unless options[:rename]
94
+ field_name = options[:rename] if options[:rename]
95
+ order = (options[:default_order].to_s.downcase.include?('desc')) ? "DESC" : "ASC"
96
+ return "#{field_name} #{order}"
97
+ end
73
98
  # Initializes a Field instance given the definition passed to the
74
99
  # scoped_search call on the ActiveRecord-based model class.
75
100
  def initialize(definition, options = {})
76
101
  @definition = definition
77
102
  @definition.profile = options[:profile] if options[:profile]
78
-
103
+ @definition.default_order ||= default_order(options)
104
+
79
105
  case options
80
106
  when Symbol, String
81
107
  @field = field.to_sym
@@ -83,18 +109,26 @@ module ScopedSearch
83
109
  @field = options.delete(:on)
84
110
 
85
111
  # Set attributes from options hash
112
+ @complete_value = options[:complete_value]
86
113
  @relation = options[:in]
114
+ @key_relation = options[:in_key]
115
+ @key_field = options[:on_key]
116
+ @offset = options[:offset]
117
+ @word_size = options[:word_size] || 1
118
+ @ext_method = options[:ext_method]
119
+ @operators = options[:operators]
87
120
  @only_explicit = !!options[:only_explicit]
88
121
  @default_operator = options[:default_operator] if options.has_key?(:default_operator)
89
122
  end
90
123
 
91
124
  # Store this field is the field array
92
- definition.fields[@field] ||= self
93
- definition.unique_fields << self
125
+ definition.fields[@field] ||= self unless options[:rename]
126
+ definition.fields[options[:rename].to_sym] ||= self if options[:rename]
127
+ definition.unique_fields << self
94
128
 
95
129
  # Store definition for alias / aliases as well
96
- definition.fields[options[:alias]] ||= self if options[:alias]
97
- options[:aliases].each { |al| definition.fields[al] ||= self } if options[:aliases]
130
+ definition.fields[options[:alias].to_sym] ||= self if options[:alias]
131
+ options[:aliases].each { |al| definition.fields[al.to_sym] ||= self } if options[:aliases]
98
132
  end
99
133
  end
100
134
 
@@ -110,10 +144,13 @@ module ScopedSearch
110
144
  @profile_fields = {:default => {}}
111
145
  @profile_unique_fields = {:default => []}
112
146
 
147
+
113
148
  register_named_scope! unless klass.respond_to?(:search_for)
149
+ register_complete_for! unless klass.respond_to?(:complete_for)
150
+
114
151
  end
115
-
116
- attr_accessor :profile
152
+
153
+ attr_accessor :profile, :default_order
117
154
 
118
155
  def fields
119
156
  @profile ||= :default
@@ -125,6 +162,25 @@ module ScopedSearch
125
162
  @profile_unique_fields[@profile] ||= []
126
163
  end
127
164
 
165
+ # this method return definitions::field object from string
166
+ def field_by_name(name)
167
+ field = fields[name.to_sym]
168
+ field ||= fields[name.to_s.split('.')[0].to_sym]
169
+ field
170
+ end
171
+
172
+ # this method is used by the syntax auto completer to suggest operators.
173
+ def operator_by_field_name(name)
174
+ field = field_by_name(name)
175
+ return [] if field.nil?
176
+ return field.operators if field.operators
177
+ return ['= ', '!= '] if field.set?
178
+ return ['= ', '> ', '< ', '<= ', '>= ','!= '] if field.numerical?
179
+ return ['= ', '!= ', '~ ', '!~ '] if field.textual?
180
+ return ['= ', '> ', '< '] if field.temporal?
181
+ raise ScopedSearch::QueryNotSupported, "could not verify '#{name}' type, this can be a result of a definition error"
182
+ end
183
+
128
184
  NUMERICAL_REGXP = /^\-?\d+(\.\d+)?$/
129
185
 
130
186
  # Returns a list of appropriate fields to search in given a search keyword and operator.
@@ -133,9 +189,20 @@ module ScopedSearch
133
189
  column_types = []
134
190
  column_types += [:string, :text] if [nil, :like, :unlike, :ne, :eq].include?(operator)
135
191
  column_types += [:integer, :double, :float, :decimal] if value =~ NUMERICAL_REGXP
136
- column_types += [:datetime, :date, :timestamp] if (DateTime.parse(value) rescue nil)
192
+ column_types += [:datetime, :date, :timestamp] if (parse_temporal(value))
137
193
 
138
- default_fields.select { |field| column_types.include?(field.type) }
194
+ default_fields.select { |field| column_types.include?(field.type) && !field.set? }
195
+ end
196
+
197
+ # Try to parse a string as a datetime.
198
+ # Supported formats are Today, Yesterday, Sunday, '1 day ago', '2 hours ago', '3 months ago','Jan 23, 2004'
199
+ # And many more formats that are documented in Ruby DateTime API Doc.
200
+ def parse_temporal(value)
201
+ return Date.current if value =~ /\btoday\b/i
202
+ return 1.day.ago.to_date if value =~ /\byesterday\b/i
203
+ return (eval(value.strip.gsub(/\s+/,'.').downcase)).to_datetime if value =~ /\A\s*\d+\s+\bhours?|minutes?\b\s+\bago\b\s*\z/i
204
+ return (eval(value.strip.gsub(/\s+/,'.').downcase)).to_date if value =~ /\A\s*\d+\s+\b(days?|weeks?|months?|years?)\b\s+\bago\b\s*\z/i
205
+ DateTime.parse(value, true) rescue nil
139
206
  end
140
207
 
141
208
  # Returns a list of fields that should be searched on by default.
@@ -165,6 +232,9 @@ module ScopedSearch
165
232
  search_scope = @klass.scoped
166
233
  search_scope = search_scope.where(find_options[:conditions]) if find_options[:conditions]
167
234
  search_scope = search_scope.includes(find_options[:include]) if find_options[:include]
235
+ search_scope = search_scope.joins(find_options[:joins]) if find_options[:joins]
236
+ search_scope = search_scope.order(find_options[:order]) if find_options[:order]
237
+ search_scope = search_scope.group(find_options[:group]) if find_options[:group]
168
238
  search_scope
169
239
  })
170
240
  else
@@ -176,3 +246,13 @@ module ScopedSearch
176
246
  end
177
247
  end
178
248
  end
249
+
250
+ # Registers the complete_for method within the class that is used for searching.
251
+ def register_complete_for! # :nodoc
252
+ @klass.class_eval do
253
+ def self.complete_for (query)
254
+ search_options = ScopedSearch::AutoCompleteBuilder.auto_complete(@scoped_search , query)
255
+ search_options
256
+ end
257
+ end
258
+ end