search_cop 1.1.0 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +42 -0
- data/.rubocop.yml +128 -0
- data/CHANGELOG.md +14 -9
- data/Gemfile +4 -5
- data/README.md +11 -1
- data/Rakefile +0 -1
- data/docker-compose.yml +8 -1
- data/gemfiles/{5.1.gemfile → rails5.gemfile} +2 -2
- data/gemfiles/rails6.gemfile +13 -0
- data/lib/search_cop.rb +5 -23
- data/lib/search_cop/grammar_parser.rb +1 -3
- data/lib/search_cop/hash_parser.rb +19 -19
- data/lib/search_cop/helpers.rb +15 -0
- data/lib/search_cop/query_builder.rb +0 -2
- data/lib/search_cop/query_info.rb +0 -2
- data/lib/search_cop/search_scope.rb +2 -4
- data/lib/search_cop/version.rb +1 -1
- data/lib/search_cop/visitors.rb +0 -2
- data/lib/search_cop/visitors/mysql.rb +4 -2
- data/lib/search_cop/visitors/postgres.rb +5 -3
- data/lib/search_cop/visitors/visitor.rb +5 -3
- data/lib/search_cop_grammar.rb +1 -3
- data/lib/search_cop_grammar/attributes.rb +45 -34
- data/lib/search_cop_grammar/nodes.rb +0 -2
- data/search_cop.gemspec +8 -8
- data/test/and_test.rb +6 -8
- data/test/boolean_test.rb +7 -9
- data/test/database.yml +2 -1
- data/test/date_test.rb +14 -16
- data/test/datetime_test.rb +15 -17
- data/test/default_operator_test.rb +14 -10
- data/test/error_test.rb +2 -4
- data/test/float_test.rb +9 -11
- data/test/fulltext_test.rb +6 -8
- data/test/hash_test.rb +32 -34
- data/test/integer_test.rb +9 -11
- data/test/not_test.rb +6 -8
- data/test/or_test.rb +8 -10
- data/test/scope_test.rb +11 -13
- data/test/search_cop_test.rb +32 -34
- data/test/string_test.rb +67 -19
- data/test/test_helper.rb +13 -15
- data/test/visitor_test.rb +4 -6
- metadata +28 -13
- data/.travis.yml +0 -34
- data/gemfiles/4.2.gemfile +0 -13
@@ -0,0 +1,15 @@
|
|
1
|
+
module SearchCop
|
2
|
+
module Helpers
|
3
|
+
def self.sanitize_default_operator(query_options)
|
4
|
+
return "and" unless query_options.key?(:default_operator)
|
5
|
+
|
6
|
+
default_operator = query_options[:default_operator].to_s.downcase
|
7
|
+
|
8
|
+
unless ["and", "or"].include?(default_operator)
|
9
|
+
raise(SearchCop::UnknownDefaultOperator, "Unknown default operator value #{default_operator}")
|
10
|
+
end
|
11
|
+
|
12
|
+
default_operator
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
class Reflection
|
4
3
|
attr_accessor :attributes, :options, :aliases, :scope, :generators
|
@@ -11,11 +10,11 @@ module SearchCop
|
|
11
10
|
end
|
12
11
|
|
13
12
|
def default_attributes
|
14
|
-
keys = options.select { |
|
13
|
+
keys = options.select { |_key, value| value[:default] == true }.keys
|
15
14
|
keys = attributes.keys.reject { |key| options[key] && options[key][:default] == false } if keys.empty?
|
16
15
|
keys = keys.to_set
|
17
16
|
|
18
|
-
attributes.select { |key,
|
17
|
+
attributes.select { |key, _value| keys.include? key }
|
19
18
|
end
|
20
19
|
end
|
21
20
|
|
@@ -64,4 +63,3 @@ module SearchCop
|
|
64
63
|
end
|
65
64
|
end
|
66
65
|
end
|
67
|
-
|
data/lib/search_cop/version.rb
CHANGED
data/lib/search_cop/visitors.rb
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
module Visitors
|
4
3
|
module Mysql
|
4
|
+
# rubocop:disable Naming/MethodName
|
5
|
+
|
5
6
|
class FulltextQuery < Visitor
|
6
7
|
def visit_SearchCopGrammar_Nodes_MatchesFulltextNot(node)
|
7
8
|
node.right.split(/[\s+'"<>()~-]+/).collect { |word| "-#{word}" }.join(" ")
|
@@ -38,6 +39,7 @@ module SearchCop
|
|
38
39
|
"MATCH(#{visit node.collection}) AGAINST(#{visit FulltextQuery.new(connection).visit(node.node)} IN BOOLEAN MODE)"
|
39
40
|
end
|
40
41
|
end
|
42
|
+
|
43
|
+
# rubocop:enable Naming/MethodName
|
41
44
|
end
|
42
45
|
end
|
43
|
-
|
@@ -1,7 +1,8 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
module Visitors
|
4
3
|
module Postgres
|
4
|
+
# rubocop:disable Naming/MethodName
|
5
|
+
|
5
6
|
class FulltextQuery < Visitor
|
6
7
|
def visit_SearchCopGrammar_Nodes_MatchesFulltextNot(node)
|
7
8
|
"!'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'"
|
@@ -21,7 +22,7 @@ module SearchCop
|
|
21
22
|
end
|
22
23
|
|
23
24
|
def visit_SearchCopGrammar_Nodes_Matches(node)
|
24
|
-
"(#{visit node.left} IS NOT NULL AND #{visit node.left} ILIKE #{visit node.right})"
|
25
|
+
"(#{visit node.left} IS NOT NULL AND #{visit node.left} ILIKE #{visit node.right} ESCAPE #{visit "\\"})"
|
25
26
|
end
|
26
27
|
|
27
28
|
def visit_SearchCopGrammar_Attributes_Collection(node)
|
@@ -41,7 +42,8 @@ module SearchCop
|
|
41
42
|
|
42
43
|
"to_tsvector(#{visit dictionary}, #{visit node.collection}) @@ to_tsquery(#{visit dictionary}, #{visit FulltextQuery.new(connection).visit(node.node)})"
|
43
44
|
end
|
45
|
+
|
46
|
+
# rubocop:enable Naming/MethodName
|
44
47
|
end
|
45
48
|
end
|
46
49
|
end
|
47
|
-
|
@@ -1,7 +1,8 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
module Visitors
|
4
3
|
class Visitor
|
4
|
+
# rubocop:disable Naming/MethodName
|
5
|
+
|
5
6
|
attr_accessor :connection
|
6
7
|
|
7
8
|
def initialize(connection)
|
@@ -48,7 +49,7 @@ module SearchCop
|
|
48
49
|
end
|
49
50
|
|
50
51
|
def visit_SearchCopGrammar_Nodes_Matches(node)
|
51
|
-
"(#{visit node.left} IS NOT NULL AND #{visit node.left} LIKE #{visit node.right})"
|
52
|
+
"(#{visit node.left} IS NOT NULL AND #{visit node.left} LIKE #{visit node.right} ESCAPE #{visit "\\"})"
|
52
53
|
end
|
53
54
|
|
54
55
|
def visit_SearchCopGrammar_Nodes_Not(node)
|
@@ -95,7 +96,8 @@ module SearchCop
|
|
95
96
|
alias :visit_Fixnum :quote
|
96
97
|
alias :visit_Symbol :quote
|
97
98
|
alias :visit_Integer :quote
|
99
|
+
|
100
|
+
# rubocop:enable Naming/MethodName
|
98
101
|
end
|
99
102
|
end
|
100
103
|
end
|
101
|
-
|
data/lib/search_cop_grammar.rb
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
require "search_cop_grammar/attributes"
|
3
2
|
require "search_cop_grammar/nodes"
|
4
3
|
|
@@ -19,7 +18,7 @@ module SearchCopGrammar
|
|
19
18
|
end
|
20
19
|
|
21
20
|
def elements
|
22
|
-
super.
|
21
|
+
super.reject { |element| element.instance_of?(Treetop::Runtime::SyntaxNode) }
|
23
22
|
end
|
24
23
|
|
25
24
|
def collection_for(key)
|
@@ -153,4 +152,3 @@ module SearchCopGrammar
|
|
153
152
|
|
154
153
|
class Value < BaseNode; end
|
155
154
|
end
|
156
|
-
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
require "treetop"
|
3
2
|
|
4
3
|
module SearchCopGrammar
|
@@ -28,9 +27,9 @@ module SearchCopGrammar
|
|
28
27
|
end
|
29
28
|
|
30
29
|
[:eq, :not_eq, :lt, :lteq, :gt, :gteq].each do |method|
|
31
|
-
|
32
|
-
|
33
|
-
|
30
|
+
define_method method do |value|
|
31
|
+
attributes.collect! { |attribute| attribute.send method, value }.inject(:or)
|
32
|
+
end
|
34
33
|
end
|
35
34
|
|
36
35
|
def generator(generator, value)
|
@@ -137,7 +136,7 @@ module SearchCopGrammar
|
|
137
136
|
false
|
138
137
|
end
|
139
138
|
|
140
|
-
{ :
|
139
|
+
{ eq: "Equality", not_eq: "NotEqual", lt: "LessThan", lteq: "LessThanOrEqual", gt: "GreaterThan", gteq: "GreaterThanOrEqual", matches: "Matches" }.each do |method, class_name|
|
141
140
|
define_method method do |value|
|
142
141
|
raise(SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}") unless compatible?(value)
|
143
142
|
|
@@ -146,19 +145,32 @@ module SearchCopGrammar
|
|
146
145
|
end
|
147
146
|
|
148
147
|
def method_missing(name, *args, &block)
|
149
|
-
@attribute.
|
148
|
+
if @attribute.respond_to?(name)
|
149
|
+
@attribute.send(name, *args, &block)
|
150
|
+
else
|
151
|
+
super
|
152
|
+
end
|
150
153
|
end
|
151
154
|
|
152
|
-
def
|
153
|
-
|
155
|
+
def respond_to_missing?(*args)
|
156
|
+
@attribute.respond_to?(*args) || super
|
154
157
|
end
|
155
158
|
end
|
156
159
|
|
157
160
|
class String < Base
|
158
161
|
def matches_value(value)
|
159
|
-
|
162
|
+
res = value.gsub(/[%_\\]/) { |char| "\\#{char}" }
|
160
163
|
|
161
|
-
|
164
|
+
if value.strip =~ /^\*|\*$/
|
165
|
+
res = res.gsub(/^\*/, "%") if options[:left_wildcard] != false
|
166
|
+
res = res.gsub(/\*$/, "%") if options[:right_wildcard] != false
|
167
|
+
|
168
|
+
return res
|
169
|
+
end
|
170
|
+
|
171
|
+
res = "%#{res}" if options[:left_wildcard] != false
|
172
|
+
res = "#{res}%" if options[:right_wildcard] != false
|
173
|
+
res
|
162
174
|
end
|
163
175
|
|
164
176
|
def matches(value)
|
@@ -176,7 +188,7 @@ module SearchCopGrammar
|
|
176
188
|
|
177
189
|
class Float < WithoutMatches
|
178
190
|
def compatible?(value)
|
179
|
-
return true if value.to_s =~
|
191
|
+
return true if value.to_s =~ /^-?[0-9]+(\.[0-9]+)?$/
|
180
192
|
|
181
193
|
false
|
182
194
|
end
|
@@ -196,24 +208,24 @@ module SearchCopGrammar
|
|
196
208
|
|
197
209
|
class Datetime < WithoutMatches
|
198
210
|
def parse(value)
|
199
|
-
return value
|
211
|
+
return value..value unless value.is_a?(::String)
|
200
212
|
|
201
213
|
if value =~ /^[0-9]+ (hour|day|week|month|year)s{0,1} (ago)$/
|
202
|
-
number,period,ago = value.split(
|
214
|
+
number, period, ago = value.split(" ")
|
203
215
|
time = number.to_i.send(period.to_sym).send(ago.to_sym)
|
204
|
-
time
|
216
|
+
time..::Time.now
|
205
217
|
elsif value =~ /^[0-9]{4}$/
|
206
|
-
::Time.new(value).beginning_of_year
|
207
|
-
elsif value =~
|
208
|
-
::Time.new(
|
209
|
-
elsif value =~
|
210
|
-
::Time.new(
|
211
|
-
elsif value =~
|
218
|
+
::Time.new(value).beginning_of_year..::Time.new(value).end_of_year
|
219
|
+
elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$}
|
220
|
+
::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).beginning_of_month..::Time.new(Regexp.last_match(1), Regexp.last_match(3), 15).end_of_month
|
221
|
+
elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$}
|
222
|
+
::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).beginning_of_month..::Time.new(Regexp.last_match(3), Regexp.last_match(1), 15).end_of_month
|
223
|
+
elsif value =~ %r{^[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}$} || value =~ %r{^[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}$}
|
212
224
|
time = ::Time.parse(value)
|
213
|
-
time.beginning_of_day
|
214
|
-
elsif value =~
|
225
|
+
time.beginning_of_day..time.end_of_day
|
226
|
+
elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}}
|
215
227
|
time = ::Time.parse(value)
|
216
|
-
time
|
228
|
+
time..time
|
217
229
|
else
|
218
230
|
raise ArgumentError
|
219
231
|
end
|
@@ -246,21 +258,21 @@ module SearchCopGrammar
|
|
246
258
|
|
247
259
|
class Date < Datetime
|
248
260
|
def parse(value)
|
249
|
-
return value
|
261
|
+
return value..value unless value.is_a?(::String)
|
250
262
|
|
251
263
|
if value =~ /^[0-9]+ (day|week|month|year)s{0,1} (ago)$/
|
252
|
-
number,period,ago = value.split(
|
264
|
+
number, period, ago = value.split(" ")
|
253
265
|
time = number.to_i.send(period.to_sym).send(ago.to_sym)
|
254
|
-
time.to_date
|
266
|
+
time.to_date..::Date.today
|
255
267
|
elsif value =~ /^[0-9]{4}$/
|
256
|
-
::Date.new(value.to_i).beginning_of_year
|
257
|
-
elsif value =~
|
258
|
-
::Date.new(
|
259
|
-
elsif value =~
|
260
|
-
::Date.new(
|
261
|
-
elsif value =~
|
268
|
+
::Date.new(value.to_i).beginning_of_year..::Date.new(value.to_i).end_of_year
|
269
|
+
elsif value =~ %r{^([0-9]{4})(\.|-|/)([0-9]{1,2})$}
|
270
|
+
::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(1).to_i, Regexp.last_match(3).to_i, 15).end_of_month
|
271
|
+
elsif value =~ %r{^([0-9]{1,2})(\.|-|/)([0-9]{4})$}
|
272
|
+
::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).beginning_of_month..::Date.new(Regexp.last_match(3).to_i, Regexp.last_match(1).to_i, 15).end_of_month
|
273
|
+
elsif value =~ %r{[0-9]{4}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{1,2}} || value =~ %r{[0-9]{1,2}(\.|-|/)[0-9]{1,2}(\.|-|/)[0-9]{4}}
|
262
274
|
date = ::Date.parse(value)
|
263
|
-
date
|
275
|
+
date..date
|
264
276
|
else
|
265
277
|
raise ArgumentError
|
266
278
|
end
|
@@ -281,4 +293,3 @@ module SearchCopGrammar
|
|
281
293
|
end
|
282
294
|
end
|
283
295
|
end
|
284
|
-
|
data/search_cop.gemspec
CHANGED
@@ -1,28 +1,28 @@
|
|
1
|
-
|
2
|
-
lib = File.expand_path('../lib', __FILE__)
|
1
|
+
lib = File.expand_path("lib", __dir__)
|
3
2
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require
|
3
|
+
require "search_cop/version"
|
5
4
|
|
6
5
|
Gem::Specification.new do |spec|
|
7
6
|
spec.name = "search_cop"
|
8
7
|
spec.version = SearchCop::VERSION
|
9
8
|
spec.authors = ["Benjamin Vetter"]
|
10
9
|
spec.email = ["vetter@flakks.com"]
|
11
|
-
spec.description =
|
12
|
-
spec.summary =
|
10
|
+
spec.description = "Search engine like fulltext query support for ActiveRecord"
|
11
|
+
spec.summary = "Easily perform complex search engine like fulltext queries on your ActiveRecord models"
|
13
12
|
spec.homepage = "https://github.com/mrkamel/search_cop"
|
14
13
|
spec.license = "MIT"
|
15
14
|
|
16
|
-
spec.files = `git ls-files`.split(
|
15
|
+
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
17
16
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
17
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
18
|
spec.require_paths = ["lib"]
|
20
19
|
|
21
20
|
spec.add_dependency "treetop"
|
22
21
|
|
23
|
-
spec.add_development_dependency "bundler"
|
24
|
-
spec.add_development_dependency "rake"
|
25
22
|
spec.add_development_dependency "activerecord", ">= 3.0.0"
|
23
|
+
spec.add_development_dependency "bundler"
|
26
24
|
spec.add_development_dependency "factory_bot"
|
27
25
|
spec.add_development_dependency "minitest"
|
26
|
+
spec.add_development_dependency "rake"
|
27
|
+
spec.add_development_dependency "rubocop"
|
28
28
|
end
|
data/test/and_test.rb
CHANGED
@@ -1,10 +1,9 @@
|
|
1
|
-
|
2
|
-
require File.expand_path("../test_helper", __FILE__)
|
1
|
+
require File.expand_path("test_helper", __dir__)
|
3
2
|
|
4
3
|
class AndTest < SearchCop::TestCase
|
5
4
|
def test_and_string
|
6
|
-
expected = create(:product, :
|
7
|
-
rejected = create(:product, :
|
5
|
+
expected = create(:product, title: "expected title", description: "Description")
|
6
|
+
rejected = create(:product, title: "Rejected title", description: "Description")
|
8
7
|
|
9
8
|
results = Product.search("title: 'Expected title' description: Description")
|
10
9
|
|
@@ -15,13 +14,12 @@ class AndTest < SearchCop::TestCase
|
|
15
14
|
end
|
16
15
|
|
17
16
|
def test_and_hash
|
18
|
-
expected = create(:product, :
|
19
|
-
rejected = create(:product, :
|
17
|
+
expected = create(:product, title: "Expected title", description: "Description")
|
18
|
+
rejected = create(:product, title: "Rejected title", description: "Description")
|
20
19
|
|
21
|
-
results = Product.search(:
|
20
|
+
results = Product.search(and: [{ title: "Expected title" }, { description: "Description" }])
|
22
21
|
|
23
22
|
assert_includes results, expected
|
24
23
|
refute_includes results, rejected
|
25
24
|
end
|
26
25
|
end
|
27
|
-
|
data/test/boolean_test.rb
CHANGED
@@ -1,15 +1,14 @@
|
|
1
|
-
|
2
|
-
require File.expand_path("../test_helper", __FILE__)
|
1
|
+
require File.expand_path("test_helper", __dir__)
|
3
2
|
|
4
3
|
class BooleanTest < SearchCop::TestCase
|
5
4
|
def test_mapping
|
6
|
-
product = create(:product, :
|
5
|
+
product = create(:product, available: true)
|
7
6
|
|
8
7
|
assert_includes Product.search("available: 1"), product
|
9
8
|
assert_includes Product.search("available: true"), product
|
10
9
|
assert_includes Product.search("available: yes"), product
|
11
10
|
|
12
|
-
product = create(:product, :
|
11
|
+
product = create(:product, available: false)
|
13
12
|
|
14
13
|
assert_includes Product.search("available: 0"), product
|
15
14
|
assert_includes Product.search("available: false"), product
|
@@ -17,28 +16,28 @@ class BooleanTest < SearchCop::TestCase
|
|
17
16
|
end
|
18
17
|
|
19
18
|
def test_anywhere
|
20
|
-
product = create(:product, :
|
19
|
+
product = create(:product, available: true)
|
21
20
|
|
22
21
|
assert_includes Product.search("true"), product
|
23
22
|
refute_includes Product.search("false"), product
|
24
23
|
end
|
25
24
|
|
26
25
|
def test_includes
|
27
|
-
product = create(:product, :
|
26
|
+
product = create(:product, available: true)
|
28
27
|
|
29
28
|
assert_includes Product.search("available: true"), product
|
30
29
|
refute_includes Product.search("available: false"), product
|
31
30
|
end
|
32
31
|
|
33
32
|
def test_equals
|
34
|
-
product = create(:product, :
|
33
|
+
product = create(:product, available: true)
|
35
34
|
|
36
35
|
assert_includes Product.search("available = true"), product
|
37
36
|
refute_includes Product.search("available = false"), product
|
38
37
|
end
|
39
38
|
|
40
39
|
def test_equals_not
|
41
|
-
product = create(:product, :
|
40
|
+
product = create(:product, available: false)
|
42
41
|
|
43
42
|
assert_includes Product.search("available != true"), product
|
44
43
|
refute_includes Product.search("available != false"), product
|
@@ -50,4 +49,3 @@ class BooleanTest < SearchCop::TestCase
|
|
50
49
|
end
|
51
50
|
end
|
52
51
|
end
|
53
|
-
|