scoped_search 4.1.4 → 4.1.9

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: cf2c98d3eed66905e04497673572661d98ca832a
4
- data.tar.gz: 5cda23768c0ffef857dc038d74abd2539bf8881c
2
+ SHA256:
3
+ metadata.gz: 9c1453ebb8fe2391f71ea35171f2115ed89bb7920a1a042d86a4393fe218888c
4
+ data.tar.gz: 61d2d0e948a952f7e955240747dae7e529103c5871ad3160cb2213574f0f155a
5
5
  SHA512:
6
- metadata.gz: 96425ef732825dfb333d977f55a55be95ef3ac9dfda0ba9c4353fc97227d5caebfc4977b8cbdb786eb5929db84885f74435b7ca8795e449dcd2f7b432b0abbf5
7
- data.tar.gz: '068d65cd7fe9e1d4e58b0080d92a22a9216ed879e17275aec5bce2651ce6cced96251803f062113c6ab66135a3ae90e478cf7b90bd2ea7b0437f5f83a96516ce'
6
+ metadata.gz: 04e61c564c6117d60b6f82b970df5c6c7a68362d828d89d1cf5d2bf7f6b7c9ea777cf641dbb2a21be895905305c0c7305105c9840d391aa702cd69eab706fd4d
7
+ data.tar.gz: 842c31b1714558d8bd4578d47b87bca4fa4298617a16168b66a77e6ff06ee1f43406b30be3557a9c50222ba5effaa9c5eec27832839e75bb11faf085f95bb7bc
@@ -1,6 +1,9 @@
1
1
  language: ruby
2
2
  cache: bundler
3
3
  sudo: false
4
+ services:
5
+ - postgresql
6
+ - mysql
4
7
 
5
8
  install:
6
9
  - bundle install
@@ -14,8 +17,11 @@ rvm:
14
17
  - "2.0"
15
18
  - "2.1"
16
19
  - "2.2.2"
17
- - "2.3.1"
20
+ - "2.3.7"
18
21
  - "2.4.0"
22
+ - "2.5.1"
23
+ - "2.6.0"
24
+ - "2.7.1"
19
25
  - ruby-head
20
26
  - jruby-19mode
21
27
  - jruby-head
@@ -24,6 +30,10 @@ gemfile:
24
30
  - Gemfile.activerecord42
25
31
  - Gemfile.activerecord50
26
32
  - Gemfile.activerecord51
33
+ - Gemfile.activerecord52
34
+ - Gemfile.activerecord52_with_activesupport52
35
+ - Gemfile.activerecord60
36
+ - Gemfile.activerecord60_with_activesupport60
27
37
 
28
38
  matrix:
29
39
  allow_failures:
@@ -39,3 +49,41 @@ matrix:
39
49
  gemfile: Gemfile.activerecord51
40
50
  - rvm: "2.1"
41
51
  gemfile: Gemfile.activerecord51
52
+ - rvm: "2.0"
53
+ gemfile: Gemfile.activerecord52
54
+ - rvm: "2.1"
55
+ gemfile: Gemfile.activerecord52
56
+ - rvm: "2.0"
57
+ gemfile: Gemfile.activerecord52_with_activesupport52
58
+ - rvm: "2.1"
59
+ gemfile: Gemfile.activerecord52_with_activesupport52
60
+ - rvm: "2.0"
61
+ gemfile: Gemfile.activerecord60
62
+ - rvm: "2.1"
63
+ gemfile: Gemfile.activerecord60
64
+ - rvm: "2.2.2"
65
+ gemfile: Gemfile.activerecord60
66
+ - rvm: "2.3.7"
67
+ gemfile: Gemfile.activerecord60
68
+ - rvm: "2.4.0"
69
+ gemfile: Gemfile.activerecord60
70
+ - rvm: "2.0"
71
+ gemfile: Gemfile.activerecord60_with_activesupport60
72
+ - rvm: "2.1"
73
+ gemfile: Gemfile.activerecord60_with_activesupport60
74
+ - rvm: "2.2.2"
75
+ gemfile: Gemfile.activerecord60_with_activesupport60
76
+ - rvm: "2.3.7"
77
+ gemfile: Gemfile.activerecord60_with_activesupport60
78
+ - rvm: "2.4.0"
79
+ gemfile: Gemfile.activerecord60_with_activesupport60
80
+ - rvm: "2.7.1"
81
+ gemfile: Gemfile.activerecord42
82
+ - rvm: "2.7.1"
83
+ gemfile: Gemfile.activerecord50
84
+ - rvm: "2.7.1"
85
+ gemfile: Gemfile.activerecord51
86
+ - rvm: "2.7.1"
87
+ gemfile: Gemfile.activerecord52
88
+ - rvm: "2.7.1"
89
+ gemfile: Gemfile.activerecord52_with_activesupport52
@@ -6,7 +6,27 @@ Please add an entry to the "Unreleased changes" section in your pull requests.
6
6
 
7
7
  === Unreleased changes
8
8
 
9
- *Nothing yet*
9
+ - Nothing yet
10
+
11
+ === Version 4.1.8
12
+
13
+ - Fix querying in associations by set, datetime or IN searches
14
+ - Add support for ActiveRecord 6.0 and Rails 6.1
15
+
16
+ === Version 4.1.7
17
+
18
+ - When Active Support is available, we parse time respecting current time zone
19
+ - Allow dots in rename definitions
20
+ - Accept extra value_filter in `complete_for`
21
+
22
+ === Version 4.1.6
23
+
24
+ - Support for UUID columns on PostgreSQL (#184)
25
+ - Allow value mapping on runtime to allow queries like "user = current" (#183)
26
+
27
+ === Version 4.1.5
28
+
29
+ - Bugfix related to auto-completion of virtual fields (#182)
10
30
 
11
31
  === Version 4.1.4
12
32
 
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'actionview', '~> 5.2.0'
5
+ gem 'activerecord', '~> 5.2.0'
6
+
7
+ platforms :jruby do
8
+ gem 'activerecord-jdbcsqlite3-adapter'
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ gem 'activerecord-jdbcpostgresql-adapter'
11
+ end
12
+
13
+ platforms :ruby do
14
+ gem 'sqlite3', '~> 1.3.6'
15
+ gem 'mysql2', '>= 0.3.18', '< 0.5'
16
+ gem 'pg', '~> 0.18'
17
+ end
@@ -0,0 +1,18 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'actionview', '~> 5.2.0'
5
+ gem 'activerecord', '~> 5.2.0'
6
+ gem 'activesupport', '~> 5.2.0'
7
+
8
+ platforms :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter'
10
+ gem 'activerecord-jdbcmysql-adapter'
11
+ gem 'activerecord-jdbcpostgresql-adapter'
12
+ end
13
+
14
+ platforms :ruby do
15
+ gem 'sqlite3', '~> 1.3.6'
16
+ gem 'mysql2', '>= 0.3.18', '< 0.5'
17
+ gem 'pg', '~> 0.18'
18
+ end
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'actionview', '~> 6.0.0'
5
+ gem 'activerecord', '~> 6.0.0'
6
+
7
+ platforms :jruby do
8
+ gem 'activerecord-jdbcsqlite3-adapter'
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ gem 'activerecord-jdbcpostgresql-adapter'
11
+ end
12
+
13
+ platforms :ruby do
14
+ gem 'sqlite3', '~> 1.4'
15
+ gem 'mysql2', '> 0.5'
16
+ gem 'pg', '>= 0.18', '< 2.0'
17
+ end
@@ -0,0 +1,18 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'actionview', '~> 6.0.0'
5
+ gem 'activerecord', '~> 6.0.0'
6
+ gem 'activesupport', '~> 6.0.0'
7
+
8
+ platforms :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter'
10
+ gem 'activerecord-jdbcmysql-adapter'
11
+ gem 'activerecord-jdbcpostgresql-adapter'
12
+ end
13
+
14
+ platforms :ruby do
15
+ gem 'sqlite3', '~> 1.4'
16
+ gem 'mysql2', '> 0.5'
17
+ gem 'pg', '>= 0.18', '< 2.0'
18
+ end
@@ -201,10 +201,17 @@ module ScopedSearch
201
201
  return complete_date_value if field.temporal?
202
202
  return complete_key_value(field, token, val) if field.key_field
203
203
 
204
+ special_values = field.special_values.select { |v| v =~ /\A#{val}/ }
205
+ special_values + complete_value_from_db(field, special_values, val)
206
+ end
207
+
208
+ def complete_value_from_db(field, special_values, val)
209
+ count = 20 - special_values.count
204
210
  completer_scope(field)
211
+ .where(@options[:value_filter])
205
212
  .where(value_conditions(field.quoted_field, val))
206
213
  .select(field.quoted_field)
207
- .limit(20)
214
+ .limit(count)
208
215
  .distinct
209
216
  .map(&field.field)
210
217
  .compact
@@ -213,8 +220,8 @@ module ScopedSearch
213
220
 
214
221
  def completer_scope(field)
215
222
  klass = field.klass
216
- scope = klass.respond_to?(:completer_scope) ? klass.completer_scope(@options) : klass
217
- scope.respond_to?(:reorder) ? scope.reorder(field.quoted_field) : scope.scoped(:order => field.quoted_field)
223
+ scope = klass.respond_to?(:completer_scope) ? klass.completer_scope(@options) : klass
224
+ scope.respond_to?(:reorder) ? scope.reorder(Arel.sql(field.quoted_field)) : scope.scoped(:order => field.quoted_field)
218
225
  end
219
226
 
220
227
  # set value completer
@@ -270,7 +277,7 @@ module ScopedSearch
270
277
 
271
278
  # This method complete infix operators by field type
272
279
  def complete_operator(node)
273
- definition.operator_by_field_name(node.value)
280
+ definition.operator_by_field_name(node.value).map { |o| o.end_with?(' ') ? o : "#{o} " }
274
281
  end
275
282
  end
276
283
  end
@@ -17,7 +17,7 @@ module ScopedSearch
17
17
 
18
18
  attr_reader :definition, :field, :only_explicit, :relation, :key_relation, :full_text_search,
19
19
  :key_field, :complete_value, :complete_enabled, :offset, :word_size, :ext_method, :operators,
20
- :validator
20
+ :validator, :value_translation, :special_values
21
21
 
22
22
  # Initializes a Field instance given the definition passed to the
23
23
  # scoped_search call on the ActiveRecord-based model class.
@@ -42,7 +42,9 @@ module ScopedSearch
42
42
  profile: nil,
43
43
  relation: nil,
44
44
  rename: nil,
45
+ special_values: [],
45
46
  validator: nil,
47
+ value_translation: nil,
46
48
  word_size: 1,
47
49
  **kwargs)
48
50
 
@@ -50,6 +52,8 @@ module ScopedSearch
50
52
  raise ArgumentError, "Missing field or 'on' keyword argument" if on.nil?
51
53
  @field = on.to_sym
52
54
 
55
+ raise ArgumentError, "'special_values' must be an Array" unless special_values.kind_of?(Array)
56
+
53
57
  # Reserved Ruby keywords so access via kwargs instead, but deprecate them for future versions
54
58
  if kwargs.key?(:in)
55
59
  relation = kwargs.delete(:in)
@@ -77,8 +81,10 @@ module ScopedSearch
77
81
  @only_explicit = !!only_explicit
78
82
  @operators = operators
79
83
  @relation = relation
84
+ @special_values = special_values
80
85
  @validator = validator
81
86
  @word_size = word_size
87
+ @value_translation = value_translation
82
88
 
83
89
  # Store this field in the field array
84
90
  definition.define_field(rename || @field, self)
@@ -107,6 +113,11 @@ module ScopedSearch
107
113
  end
108
114
  end
109
115
 
116
+ # Returns true if this is a virtual field.
117
+ def virtual?
118
+ !ext_method.nil?
119
+ end
120
+
110
121
  # Returns the ActiveRecord column definition that corresponds to this field.
111
122
  def column
112
123
  @column ||= begin
@@ -120,15 +131,15 @@ module ScopedSearch
120
131
 
121
132
  # Returns the column type of this field.
122
133
  def type
123
- @type ||= column.type
134
+ @type ||= virtual? ? :virtual : column.type
124
135
  end
125
136
 
126
- # Returns true if this field is a datetime-like column
137
+ # Returns true if this field is a datetime-like column.
127
138
  def datetime?
128
139
  [:datetime, :time, :timestamp].include?(type)
129
140
  end
130
141
 
131
- # Returns true if this field is a date-like column
142
+ # Returns true if this field is a date-like column.
132
143
  def date?
133
144
  type == :date
134
145
  end
@@ -149,6 +160,10 @@ module ScopedSearch
149
160
  [:string, :text].include?(type)
150
161
  end
151
162
 
163
+ def uuid?
164
+ type == :uuid
165
+ end
166
+
152
167
  # Returns true if this is a set.
153
168
  def set?
154
169
  complete_value.is_a?(Hash)
@@ -167,7 +182,7 @@ module ScopedSearch
167
182
  return "#{field} #{order}"
168
183
  end
169
184
 
170
- # Return 'table'.'column' with the correct database quotes
185
+ # Return 'table'.'column' with the correct database quotes.
171
186
  def quoted_field
172
187
  c = klass.connection
173
188
  "#{c.quote_table_name(klass.table_name)}.#{c.quote_column_name(field)}"
@@ -223,6 +238,9 @@ module ScopedSearch
223
238
  if field.nil?
224
239
  dotted = name.to_s.split('.')[0]
225
240
  field = fields[dotted.to_sym] unless dotted.blank?
241
+ if field && field.key_relation.nil?
242
+ return nil
243
+ end
226
244
  end
227
245
  field
228
246
  end
@@ -231,24 +249,27 @@ module ScopedSearch
231
249
  def operator_by_field_name(name)
232
250
  field = field_by_name(name)
233
251
  return [] if field.nil?
234
- return field.operators if field.operators
235
- return ['= ', '!= '] if field.set?
236
- return ['= ', '> ', '< ', '<= ', '>= ','!= ', '^ ', '!^ '] if field.numerical?
237
- return ['= ', '!= ', '~ ', '!~ ', '^ ', '!^ '] if field.textual?
238
- return ['= ', '> ', '< '] if field.temporal?
252
+ return field.operators if field.operators
253
+ return ['=', '!=', '>', '<', '<=', '>=', '~', '!~', '^', '!^'] if field.virtual?
254
+ return ['=', '!='] if field.set? || field.uuid?
255
+ return ['=', '>', '<', '<=', '>=', '!=', '^', '!^'] if field.numerical?
256
+ return ['=', '!=', '~', '!~', '^', '!^'] if field.textual?
257
+ return ['=', '>', '<'] if field.temporal?
239
258
  raise ScopedSearch::QueryNotSupported, "Unsupported type '#{field.type.inspect}')' for field '#{name}'. This can be a result of a search definition problem."
240
259
  end
241
260
 
242
261
  NUMERICAL_REGXP = /^\-?\d+(\.\d+)?$/
243
262
  INTEGER_REGXP = /^\-?\d+$/
263
+ UUID_REGXP = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
244
264
 
245
265
  # Returns a list of appropriate fields to search in given a search keyword and operator.
246
266
  def default_fields_for(value, operator = nil)
247
267
 
248
- column_types = []
268
+ column_types = [:virtual]
249
269
  column_types += [:string, :text] if [nil, :like, :unlike, :ne, :eq].include?(operator)
250
270
  column_types += [:double, :float, :decimal] if value =~ NUMERICAL_REGXP
251
271
  column_types += [:integer] if value =~ INTEGER_REGXP
272
+ column_types += [:uuid] if value =~ UUID_REGXP
252
273
  column_types += [:datetime, :date, :timestamp] if (parse_temporal(value))
253
274
 
254
275
  default_fields.select { |field| !field.set? && column_types.include?(field.type) }
@@ -257,6 +278,8 @@ module ScopedSearch
257
278
  # Try to parse a string as a datetime.
258
279
  # Supported formats are Today, Yesterday, Sunday, '1 day ago', '2 hours ago', '3 months ago', '4 weeks from now', 'Jan 23, 2004'
259
280
  # And many more formats that are documented in Ruby DateTime API Doc.
281
+ # In case Time responds to #zone, we know this is Rails environment and we can use Time.zone.parse. The benefit is that the
282
+ # current timezone is respected and does not have to be specified explicitly. That way even relative dates work as expected.
260
283
  def parse_temporal(value)
261
284
  return Date.current if value =~ /\btoday\b/i
262
285
  return 1.day.ago.to_date if value =~ /\byesterday\b/i
@@ -265,7 +288,12 @@ module ScopedSearch
265
288
  return (eval($1.strip.gsub(/\s+/,'.').downcase)).to_date if value =~ /\A\s*(\d+\s+\b(?:days?|weeks?|months?|years?)\b\s+\bago)\b\s*\z/i
266
289
  return (eval($1.strip.gsub(/from\s+now/i,'from_now').gsub(/\s+/,'.').downcase)).to_datetime if value =~ /\A\s*(\d+\s+\b(?:hours?|minutes?)\b\s+\bfrom\s+now)\b\s*\z/i
267
290
  return (eval($1.strip.gsub(/from\s+now/i,'from_now').gsub(/\s+/,'.').downcase)).to_date if value =~ /\A\s*(\d+\s+\b(?:days?|weeks?|months?|years?)\b\s+\bfrom\s+now)\b\s*\z/i
268
- DateTime.parse(value, true) rescue nil
291
+ if Time.respond_to?(:zone) && !Time.zone.nil?
292
+ parsed = Time.zone.parse(value) rescue nil
293
+ parsed && parsed.to_datetime
294
+ else
295
+ DateTime.parse(value, true) rescue nil
296
+ end
269
297
  end
270
298
 
271
299
  # Returns a list of fields that should be searched on by default.
@@ -298,11 +326,11 @@ module ScopedSearch
298
326
 
299
327
  search_scope = klass.all
300
328
  find_options = ScopedSearch::QueryBuilder.build_query(definition, query || '', options)
301
- search_scope = search_scope.where(find_options[:conditions]) if find_options[:conditions]
302
- search_scope = search_scope.includes(find_options[:include]) if find_options[:include]
303
- search_scope = search_scope.joins(find_options[:joins]) if find_options[:joins]
304
- search_scope = search_scope.reorder(find_options[:order]) if find_options[:order]
305
- search_scope = search_scope.references(find_options[:include]) if find_options[:include]
329
+ search_scope = search_scope.where(find_options[:conditions]) if find_options[:conditions]
330
+ search_scope = search_scope.includes(find_options[:include]) if find_options[:include]
331
+ search_scope = search_scope.joins(find_options[:joins]) if find_options[:joins]
332
+ search_scope = search_scope.reorder(Arel.sql(find_options[:order])) if find_options[:order]
333
+ search_scope = search_scope.references(find_options[:include]) if find_options[:include]
306
334
 
307
335
  search_scope
308
336
  end
@@ -113,7 +113,7 @@ module ScopedSearch
113
113
  # By default, it will simply look up the correct SQL operator in the SQL_OPERATORS
114
114
  # hash, but this can be overridden by a database adapter.
115
115
  def sql_operator(operator, field)
116
- raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if !field.ext_method and [:like, :unlike].include?(operator) and !field.textual?
116
+ raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if !field.virtual? and [:like, :unlike].include?(operator) and !field.textual?
117
117
  SQL_OPERATORS[operator]
118
118
  end
119
119
 
@@ -137,7 +137,7 @@ module ScopedSearch
137
137
 
138
138
  # Parse the value as a date/time and ignore invalid timestamps
139
139
  timestamp = definition.parse_temporal(value)
140
- return nil unless timestamp
140
+ return [] unless timestamp
141
141
 
142
142
  timestamp = timestamp.to_date if field.date?
143
143
  # Check for the case that a date-only value is given as search keyword,
@@ -149,11 +149,9 @@ module ScopedSearch
149
149
  if [:eq, :ne].include?(operator)
150
150
  # Instead of looking for an exact (non-)match, look for dates that
151
151
  # fall inside/outside the range of timestamps of that day.
152
- yield(:parameter, timestamp)
153
- yield(:parameter, timestamp + span)
154
152
  negate = (operator == :ne) ? 'NOT ' : ''
155
153
  field_sql = field.to_sql(operator, &block)
156
- return "#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)"
154
+ return ["#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)", timestamp, timestamp + span]
157
155
 
158
156
  elsif operator == :gt
159
157
  # Make sure timestamps on the given date are not included in the results
@@ -169,9 +167,8 @@ module ScopedSearch
169
167
  end
170
168
  end
171
169
 
172
- # Yield the timestamp and return the SQL test
173
- yield(:parameter, timestamp)
174
- "#{field.to_sql(operator, &block)} #{sql_operator(operator, field)} ?"
170
+ # return the SQL test
171
+ ["#{field.to_sql(operator, &block)} #{sql_operator(operator, field)} ?", timestamp]
175
172
  end
176
173
 
177
174
  # Validate the key name is in the set and translate the value to the set value.
@@ -181,6 +178,14 @@ module ScopedSearch
181
178
  translated_value
182
179
  end
183
180
 
181
+ def map_value(field, value)
182
+ old_value = value
183
+ translator = field.value_translation
184
+ value = translator.call(value) if translator
185
+ raise ScopedSearch::QueryNotSupported, "Translation from any value to nil is not allowed, translated '#{old_value}'" if value.nil?
186
+ value
187
+ end
188
+
184
189
  # A 'set' is group of possible values, for example a status might be "on", "off" or "unknown" and the database representation
185
190
  # could be for example a numeric value. This method will validate the input and translate it into the database representation.
186
191
  def set_test(field, operator,value, &block)
@@ -197,8 +202,7 @@ module ScopedSearch
197
202
  set_value = false
198
203
  end
199
204
  end
200
- yield(:parameter, set_value)
201
- return "#{negate}(#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?)"
205
+ ["#{negate}(#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?)", set_value]
202
206
  end
203
207
 
204
208
  # Generates a simple SQL test expression, for a field and value using an operator.
@@ -210,43 +214,51 @@ module ScopedSearch
210
214
  # <tt>operator</tt>:: The operator used for comparison.
211
215
  # <tt>value</tt>:: The value to compare the field with.
212
216
  def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
213
- return field.to_ext_method_sql(lhs, sql_operator(operator, field), value, &block) if field.ext_method
217
+ return field.to_ext_method_sql(lhs, sql_operator(operator, field), value, &block) if field.virtual?
214
218
 
215
219
  yield(:keyparameter, lhs.sub(/^.*\./,'')) if field.key_field
216
220
 
217
- if [:like, :unlike].include?(operator)
218
- yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%'))
219
- return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
220
-
221
- elsif [:in, :notin].include?(operator)
222
- value.split(',').collect { |v| yield(:parameter, field.set? ? translate_value(field, v) : v.strip) }
223
- value = value.split(',').collect { "?" }.join(",")
224
- return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} (#{value})"
221
+ condition, *values = if field.temporal?
222
+ datetime_test(field, operator, value, &block)
223
+ elsif field.set?
224
+ set_test(field, operator, value, &block)
225
+ else
226
+ ["#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} #{value_placeholders(operator, value)}", value]
227
+ end
228
+ values.each { |value| preprocess_parameters(field, operator, value, &block) }
225
229
 
226
- elsif field.temporal?
227
- return datetime_test(field, operator, value, &block)
230
+ if field.relation && definition.reflection_by_name(field.definition.klass, field.relation).macro == :has_many
231
+ connection = field.definition.klass.connection
232
+ reflection = definition.reflection_by_name(field.definition.klass, field.relation)
233
+ primary_key_col = reflection.options[:primary_key] || field.definition.klass.primary_key
234
+ primary_key = "#{connection.quote_table_name(field.definition.klass.table_name)}.#{connection.quote_column_name(primary_key_col)}"
235
+ key, join_table = if reflection.options.has_key?(:through)
236
+ [primary_key, has_many_through_join(field)]
237
+ else
238
+ [connection.quote_column_name(field.reflection_keys(reflection)[1]),
239
+ connection.quote_table_name(field.klass.table_name)]
240
+ end
241
+
242
+ condition = "#{primary_key} IN (SELECT #{key} FROM #{join_table} WHERE #{condition} )"
243
+ end
244
+ condition
245
+ end
228
246
 
229
- elsif field.set?
230
- return set_test(field, operator, value, &block)
247
+ def preprocess_parameters(field, operator, value, &block)
248
+ values = if [:in, :notin].include?(operator)
249
+ value.split(',').map { |v| map_value(field, field.set? ? translate_value(field, v) : v.strip) }
250
+ elsif [:like, :unlike].include?(operator)
251
+ [(value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%')]
252
+ else
253
+ [map_value(field, field.offset ? value.to_i : value)]
254
+ end
255
+ values.each { |value| yield(:parameter, value) }
256
+ end
231
257
 
232
- elsif field.relation && definition.reflection_by_name(field.definition.klass, field.relation).macro == :has_many
233
- value = value.to_i if field.offset
234
- yield(:parameter, value)
235
- connection = field.definition.klass.connection
236
- primary_key = "#{connection.quote_table_name(field.definition.klass.table_name)}.#{connection.quote_column_name(field.definition.klass.primary_key)}"
237
- if definition.reflection_by_name(field.definition.klass, field.relation).options.has_key?(:through)
238
- join = has_many_through_join(field)
239
- return "#{primary_key} IN (SELECT #{primary_key} FROM #{join} WHERE #{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ? )"
240
- else
241
- foreign_key = connection.quote_column_name(field.reflection_keys(definition.reflection_by_name(field.definition.klass, field.relation))[1])
242
- return "#{primary_key} IN (SELECT #{foreign_key} FROM #{connection.quote_table_name(field.klass.table_name)} WHERE #{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ? )"
243
- end
258
+ def value_placeholders(operator, value)
259
+ return '?' unless [:in, :notin].include?(operator)
244
260
 
245
- else
246
- value = value.to_i if field.offset
247
- yield(:parameter, value)
248
- return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
249
- end
261
+ '(' + value.split(',').map { '?' }.join(',') + ')'
250
262
  end
251
263
 
252
264
  def find_has_many_through_association(field, through)
@@ -259,46 +271,46 @@ module ScopedSearch
259
271
  middle_table_association
260
272
  end
261
273
 
274
+ # Walk the chain of has-many-throughs, collecting all tables we will need to join
275
+ def nested_has_many(many_class, relation)
276
+ acc = [relation]
277
+ while (reflection = definition.reflection_by_name(many_class, relation))
278
+ break if reflection.nil? || reflection.options[:through].nil?
279
+ relation = reflection.options[:through]
280
+ acc.unshift(relation)
281
+ end
282
+ acc.map { |relation| definition.reflection_by_name(many_class, relation) }
283
+ end
284
+
262
285
  def has_many_through_join(field)
263
286
  many_class = field.definition.klass
264
- through = definition.reflection_by_name(many_class, field.relation).options[:through]
265
- through_class = definition.reflection_by_name(many_class, through).klass
266
-
267
287
  connection = many_class.connection
268
-
269
- # table names
270
- endpoint_table_name = field.klass.table_name
271
- many_table_name = many_class.table_name
272
- middle_table_name = through_class.table_name
273
-
274
- # primary and foreign keys + optional conditions for the joins
275
- pk1, fk1 = field.reflection_keys(definition.reflection_by_name(many_class, through))
276
- condition_many_to_middle = if with_polymorphism?(many_class, field.klass, through, through_class)
277
- field.reflection_conditions(definition.reflection_by_name(field.klass, many_table_name))
278
- else
279
- ''
280
- end
281
- condition_middle_to_end = field.reflection_conditions(definition.reflection_by_name(field.klass, middle_table_name))
282
-
283
- # primary and foreign keys + optional condition for the endpoint to middle join
284
- middle_table_association = find_has_many_through_association(field, through) || middle_table_name
285
- pk2, fk2 = field.reflection_keys(definition.reflection_by_name(field.klass, middle_table_association))
286
- condition2 = field.reflection_conditions(definition.reflection_by_name(many_class, field.relation))
287
-
288
- <<-SQL
289
- #{connection.quote_table_name(many_table_name)}
290
- INNER JOIN #{connection.quote_table_name(middle_table_name)}
291
- ON #{connection.quote_table_name(many_table_name)}.#{connection.quote_column_name(pk1)} = #{connection.quote_table_name(middle_table_name)}.#{connection.quote_column_name(fk1)} #{condition_many_to_middle} #{condition_middle_to_end}
292
- INNER JOIN #{connection.quote_table_name(endpoint_table_name)}
293
- ON #{connection.quote_table_name(middle_table_name)}.#{connection.quote_column_name(fk2)} = #{connection.quote_table_name(endpoint_table_name)}.#{connection.quote_column_name(pk2)} #{condition2}
294
- SQL
288
+ sql = connection.quote_table_name(many_class.table_name)
289
+ join_reflections = nested_has_many(many_class, field.relation)
290
+ table_names = [many_class.table_name] + join_reflections.map(&:table_name)
291
+
292
+ join_reflections.zip(table_names.zip(join_reflections.drop(1))).reduce(sql) do |acc, (reflection, (previous_table, next_reflection))|
293
+ klass = reflection.method(:join_keys).arity == 1 ? [reflection.klass] : [] # ActiveRecord <5.2 workaround
294
+ fk1, pk1 = reflection.join_keys(*klass).values # We are joining the tables "in reverse", so the PK and FK are swapped
295
+
296
+ # primary and foreign keys + optional conditions for the joins
297
+ join_condition = if with_polymorphism?(reflection)
298
+ field.reflection_conditions(definition.reflection_by_name(next_reflection.klass, previous_table))
299
+ else
300
+ ''
301
+ end
302
+
303
+ acc + <<-SQL
304
+ INNER JOIN #{connection.quote_table_name(reflection.table_name)}
305
+ ON #{connection.quote_table_name(previous_table)}.#{connection.quote_column_name(pk1)} = #{connection.quote_table_name(reflection.table_name)}.#{connection.quote_column_name(fk1)} #{join_condition}
306
+ SQL
307
+ end
295
308
  end
296
309
 
297
- def with_polymorphism?(many_class, endpoint_class, through, through_class)
298
- reflections = [definition.reflection_by_name(endpoint_class, through), definition.reflection_by_name(many_class, through)].compact
299
- as = reflections.map(&:options).compact.map { |opt| opt[:as] }.compact
300
- return false if as.empty?
301
- definition.reflection_by_name(through_class, as.first).options[:polymorphic]
310
+ def with_polymorphism?(reflection)
311
+ as = reflection.options[:as]
312
+ return unless as
313
+ definition.reflection_by_name(reflection.klass, as).options[:polymorphic]
302
314
  end
303
315
 
304
316
  # This module gets included into the Field class to add SQL generation.
@@ -403,7 +415,7 @@ module ScopedSearch
403
415
  def to_ext_method_sql(key, operator, value, &block)
404
416
  raise ScopedSearch::QueryNotSupported, "'#{definition.klass}' doesn't respond to '#{ext_method}'" unless definition.klass.respond_to?(ext_method)
405
417
  begin
406
- conditions = definition.klass.send(ext_method.to_sym, key, operator, value)
418
+ conditions = definition.klass.send(ext_method.to_sym, key, operator, value)
407
419
  rescue StandardError => e
408
420
  raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' failed with error: #{e}"
409
421
  end
@@ -517,7 +529,7 @@ module ScopedSearch
517
529
  def validate_value(field, value)
518
530
  validator = field.validator
519
531
  if validator
520
- valid = validator.call(value)
532
+ valid = field.special_values.include?(value) || validator.call(value)
521
533
  raise ScopedSearch::QueryNotSupported, "Value '#{value}' is not valid for field '#{field.field}'" unless valid
522
534
  end
523
535
  end
@@ -554,7 +566,7 @@ module ScopedSearch
554
566
  # Switches out the default LIKE operator in the default <tt>sql_operator</tt>
555
567
  # method for ILIKE or @@ if full text searching is enabled.
556
568
  def sql_operator(operator, field)
557
- raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if !field.ext_method and [:like, :unlike].include?(operator) and !field.textual?
569
+ raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if !field.virtual? and [:like, :unlike].include?(operator) and !field.textual?
558
570
  return '@@' if [:like, :unlike].include?(operator) && field.full_text_search
559
571
  case operator
560
572
  when :like then 'ILIKE'