search_cop 1.1.0 → 1.2.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.
- 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
|
-
|