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 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