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 +5 -5
- data/.travis.yml +49 -1
- data/CHANGELOG.rdoc +21 -1
- data/Gemfile.activerecord52 +17 -0
- data/Gemfile.activerecord52_with_activesupport52 +18 -0
- data/Gemfile.activerecord60 +17 -0
- data/Gemfile.activerecord60_with_activesupport60 +18 -0
- data/lib/scoped_search/auto_complete_builder.rb +11 -4
- data/lib/scoped_search/definition.rb +45 -17
- data/lib/scoped_search/query_builder.rb +90 -78
- data/lib/scoped_search/version.rb +1 -1
- data/spec/integration/auto_complete_spec.rb +41 -4
- data/spec/integration/key_value_querying_spec.rb +15 -0
- data/spec/integration/nested_has_many_through_querying_spec.rb +100 -0
- data/spec/integration/relation_querying_spec.rb +63 -1
- data/spec/integration/sti_querying_spec.rb +12 -2
- data/spec/integration/uuid_query_spec.rb +57 -0
- data/spec/lib/database.rb +1 -1
- data/spec/spec_helper.rb +3 -0
- data/spec/unit/auto_complete_builder_spec.rb +32 -1
- data/spec/unit/query_builder_spec.rb +46 -2
- metadata +11 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9c1453ebb8fe2391f71ea35171f2115ed89bb7920a1a042d86a4393fe218888c
|
4
|
+
data.tar.gz: 61d2d0e948a952f7e955240747dae7e529103c5871ad3160cb2213574f0f155a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 04e61c564c6117d60b6f82b970df5c6c7a68362d828d89d1cf5d2bf7f6b7c9ea777cf641dbb2a21be895905305c0c7305105c9840d391aa702cd69eab706fd4d
|
7
|
+
data.tar.gz: 842c31b1714558d8bd4578d47b87bca4fa4298617a16168b66a77e6ff06ee1f43406b30be3557a9c50222ba5effaa9c5eec27832839e75bb11faf085f95bb7bc
|
data/.travis.yml
CHANGED
@@ -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.
|
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
|
data/CHANGELOG.rdoc
CHANGED
@@ -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
|
-
|
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(
|
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 =
|
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
|
235
|
-
return ['=
|
236
|
-
return ['=
|
237
|
-
return ['=
|
238
|
-
return ['= ', '
|
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
|
-
|
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])
|
302
|
-
search_scope = search_scope.includes(find_options[:include])
|
303
|
-
search_scope = search_scope.joins(find_options[:joins])
|
304
|
-
search_scope = search_scope.reorder(find_options[:order])
|
305
|
-
search_scope = search_scope.references(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.
|
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
|
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
|
-
#
|
173
|
-
|
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
|
-
|
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.
|
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
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
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
|
-
|
227
|
-
|
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
|
-
|
230
|
-
|
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
|
-
|
233
|
-
|
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
|
-
|
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
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
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?(
|
298
|
-
|
299
|
-
|
300
|
-
|
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.
|
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'
|