search_cop 1.0.6 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +42 -0
- data/.rubocop.yml +128 -0
- data/CHANGELOG.md +36 -5
- data/CONTRIBUTING.md +18 -0
- data/Gemfile +4 -17
- data/README.md +143 -35
- data/Rakefile +0 -1
- data/docker-compose.yml +18 -0
- data/gemfiles/rails5.gemfile +13 -0
- data/gemfiles/rails6.gemfile +13 -0
- data/lib/search_cop.rb +15 -13
- data/lib/search_cop/grammar_parser.rb +3 -4
- data/lib/search_cop/hash_parser.rb +23 -17
- data/lib/search_cop/helpers.rb +15 -0
- data/lib/search_cop/query_builder.rb +2 -4
- data/lib/search_cop/query_info.rb +0 -2
- data/lib/search_cop/search_scope.rb +8 -5
- data/lib/search_cop/version.rb +1 -1
- data/lib/search_cop/visitors.rb +0 -2
- data/lib/search_cop/visitors/mysql.rb +9 -7
- data/lib/search_cop/visitors/postgres.rb +18 -8
- data/lib/search_cop/visitors/visitor.rb +13 -6
- data/lib/search_cop_grammar.rb +18 -9
- data/lib/search_cop_grammar.treetop +6 -4
- data/lib/search_cop_grammar/attributes.rb +77 -34
- data/lib/search_cop_grammar/nodes.rb +7 -2
- data/search_cop.gemspec +9 -10
- data/test/and_test.rb +6 -8
- data/test/boolean_test.rb +7 -9
- data/test/database.yml +4 -1
- data/test/date_test.rb +38 -12
- data/test/datetime_test.rb +45 -12
- data/test/default_operator_test.rb +51 -0
- data/test/error_test.rb +2 -4
- data/test/float_test.rb +16 -11
- data/test/fulltext_test.rb +8 -10
- data/test/hash_test.rb +39 -31
- 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 +36 -34
- data/test/string_test.rb +67 -19
- data/test/test_helper.rb +24 -18
- data/test/visitor_test.rb +15 -8
- metadata +41 -55
- data/.travis.yml +0 -34
- data/Appraisals +0 -20
- data/gemfiles/3.2.gemfile +0 -26
- data/gemfiles/4.0.gemfile +0 -26
- data/gemfiles/4.1.gemfile +0 -26
- data/gemfiles/4.2.gemfile +0 -26
data/Rakefile
CHANGED
data/docker-compose.yml
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
|
2
|
+
version: '2'
|
3
|
+
services:
|
4
|
+
mysql:
|
5
|
+
image: percona:5.7
|
6
|
+
environment:
|
7
|
+
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
|
8
|
+
- MYSQL_ROOT_PASSWORD=
|
9
|
+
ports:
|
10
|
+
- 3306:3306
|
11
|
+
postgres:
|
12
|
+
image: postgres:9.6.6
|
13
|
+
environment:
|
14
|
+
POSTGRES_DB: search_cop
|
15
|
+
POSTGRES_USER: search_cop
|
16
|
+
POSTGRES_PASSWORD: secret
|
17
|
+
ports:
|
18
|
+
- 5432:5432
|
data/lib/search_cop.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
|
2
1
|
require "search_cop/version"
|
2
|
+
require "search_cop/helpers"
|
3
3
|
require "search_cop/search_scope"
|
4
4
|
require "search_cop/query_info"
|
5
5
|
require "search_cop/query_builder"
|
@@ -8,21 +8,24 @@ require "search_cop/hash_parser"
|
|
8
8
|
require "search_cop/visitors"
|
9
9
|
|
10
10
|
module SearchCop
|
11
|
-
class
|
11
|
+
class Error < StandardError; end
|
12
|
+
|
13
|
+
class SpecificationError < Error; end
|
12
14
|
class UnknownAttribute < SpecificationError; end
|
15
|
+
class UnknownDefaultOperator < SpecificationError; end
|
13
16
|
|
14
|
-
class RuntimeError <
|
17
|
+
class RuntimeError < Error; end
|
15
18
|
class UnknownColumn < RuntimeError; end
|
16
19
|
class NoSearchableAttributes < RuntimeError; end
|
17
20
|
class IncompatibleDatatype < RuntimeError; end
|
18
21
|
class ParseError < RuntimeError; end
|
19
22
|
|
20
23
|
module Parser
|
21
|
-
def self.parse(query, query_info)
|
24
|
+
def self.parse(query, query_info, query_options = {})
|
22
25
|
if query.is_a?(Hash)
|
23
|
-
SearchCop::HashParser.new(query_info).parse(query)
|
26
|
+
SearchCop::HashParser.new(query_info).parse(query, query_options)
|
24
27
|
else
|
25
|
-
SearchCop::GrammarParser.new(query_info).parse(query)
|
28
|
+
SearchCop::GrammarParser.new(query_info).parse(query, query_options)
|
26
29
|
end
|
27
30
|
end
|
28
31
|
end
|
@@ -41,24 +44,24 @@ module SearchCop
|
|
41
44
|
search_scopes[name] = SearchScope.new(name, self)
|
42
45
|
search_scopes[name].instance_exec(&block)
|
43
46
|
|
44
|
-
|
45
|
-
|
47
|
+
send(:define_singleton_method, name) { |query, query_options = {}| search_cop(query, name, query_options) }
|
48
|
+
send(:define_singleton_method, "unsafe_#{name}") { |query, query_options = {}| unsafe_search_cop(query, name, query_options) }
|
46
49
|
end
|
47
50
|
|
48
51
|
def search_reflection(scope_name)
|
49
52
|
search_scopes[scope_name].reflection
|
50
53
|
end
|
51
54
|
|
52
|
-
def search_cop(query, scope_name)
|
53
|
-
unsafe_search_cop
|
55
|
+
def search_cop(query, scope_name, query_options)
|
56
|
+
unsafe_search_cop(query, scope_name, query_options)
|
54
57
|
rescue SearchCop::RuntimeError
|
55
58
|
respond_to?(:none) ? none : where("1 = 0")
|
56
59
|
end
|
57
60
|
|
58
|
-
def unsafe_search_cop(query, scope_name)
|
61
|
+
def unsafe_search_cop(query, scope_name, query_options)
|
59
62
|
return respond_to?(:scoped) ? scoped : all if query.blank?
|
60
63
|
|
61
|
-
query_builder = QueryBuilder.new(self, query, search_scopes[scope_name])
|
64
|
+
query_builder = QueryBuilder.new(self, query, search_scopes[scope_name], query_options)
|
62
65
|
|
63
66
|
scope = instance_exec(&search_scopes[scope_name].reflection.scope) if search_scopes[scope_name].reflection.scope
|
64
67
|
scope ||= eager_load(query_builder.associations) if query_builder.associations.any?
|
@@ -67,4 +70,3 @@ module SearchCop
|
|
67
70
|
end
|
68
71
|
end
|
69
72
|
end
|
70
|
-
|
@@ -1,8 +1,7 @@
|
|
1
|
-
|
2
1
|
require "search_cop_grammar"
|
3
2
|
require "treetop"
|
4
3
|
|
5
|
-
Treetop.load File.expand_path("
|
4
|
+
Treetop.load File.expand_path("../search_cop_grammar.treetop", __dir__)
|
6
5
|
|
7
6
|
module SearchCop
|
8
7
|
class GrammarParser
|
@@ -12,11 +11,11 @@ module SearchCop
|
|
12
11
|
@query_info = query_info
|
13
12
|
end
|
14
13
|
|
15
|
-
def parse(string)
|
14
|
+
def parse(string, query_options)
|
16
15
|
node = SearchCopGrammarParser.new.parse(string) || raise(ParseError)
|
17
16
|
node.query_info = query_info
|
17
|
+
node.query_options = query_options
|
18
18
|
node.evaluate
|
19
19
|
end
|
20
20
|
end
|
21
21
|
end
|
22
|
-
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
class SearchCop::HashParser
|
3
2
|
attr_reader :query_info
|
4
3
|
|
@@ -6,23 +5,25 @@ class SearchCop::HashParser
|
|
6
5
|
@query_info = query_info
|
7
6
|
end
|
8
7
|
|
9
|
-
def parse(hash)
|
8
|
+
def parse(hash, query_options = {})
|
9
|
+
default_operator = SearchCop::Helpers.sanitize_default_operator(query_options)
|
10
|
+
|
10
11
|
res = hash.collect do |key, value|
|
11
12
|
case key
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
13
|
+
when :and
|
14
|
+
value.collect { |val| parse val }.inject(:and)
|
15
|
+
when :or
|
16
|
+
value.collect { |val| parse val }.inject(:or)
|
17
|
+
when :not
|
18
|
+
parse(value).not
|
19
|
+
when :query
|
20
|
+
SearchCop::Parser.parse(value, query_info)
|
21
|
+
else
|
22
|
+
parse_attribute(key, value)
|
22
23
|
end
|
23
24
|
end
|
24
25
|
|
25
|
-
res.inject
|
26
|
+
res.inject(default_operator)
|
26
27
|
end
|
27
28
|
|
28
29
|
private
|
@@ -31,12 +32,17 @@ class SearchCop::HashParser
|
|
31
32
|
collection = SearchCopGrammar::Attributes::Collection.new(query_info, key.to_s)
|
32
33
|
|
33
34
|
if value.is_a?(Hash)
|
34
|
-
raise(SearchCop::ParseError, "Unknown operator #{value.keys.first}") unless
|
35
|
+
raise(SearchCop::ParseError, "Unknown operator #{value.keys.first}") unless collection.valid_operator?(value.keys.first)
|
36
|
+
|
37
|
+
generator = collection.generator_for(value.keys.first)
|
35
38
|
|
36
|
-
|
39
|
+
if generator
|
40
|
+
collection.generator(generator, value.values.first)
|
41
|
+
else
|
42
|
+
collection.send(value.keys.first, value.values.first.to_s)
|
43
|
+
end
|
37
44
|
else
|
38
|
-
collection.send
|
45
|
+
collection.send(:matches, value.to_s)
|
39
46
|
end
|
40
47
|
end
|
41
48
|
end
|
42
|
-
|
@@ -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,13 +1,12 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
class QueryBuilder
|
4
3
|
attr_accessor :query_info, :scope, :sql
|
5
4
|
|
6
|
-
def initialize(model, query, scope)
|
5
|
+
def initialize(model, query, scope, query_options)
|
7
6
|
self.scope = scope
|
8
7
|
self.query_info = QueryInfo.new(model, scope)
|
9
8
|
|
10
|
-
arel = SearchCop::Parser.parse(query, query_info).optimize!
|
9
|
+
arel = SearchCop::Parser.parse(query, query_info, query_options).optimize!
|
11
10
|
|
12
11
|
self.sql = SearchCop::Visitors::Visitor.new(model.connection).visit(arel)
|
13
12
|
end
|
@@ -32,4 +31,3 @@ module SearchCop
|
|
32
31
|
end
|
33
32
|
end
|
34
33
|
end
|
35
|
-
|
@@ -1,20 +1,20 @@
|
|
1
|
-
|
2
1
|
module SearchCop
|
3
2
|
class Reflection
|
4
|
-
attr_accessor :attributes, :options, :aliases, :scope
|
3
|
+
attr_accessor :attributes, :options, :aliases, :scope, :generators
|
5
4
|
|
6
5
|
def initialize
|
7
6
|
self.attributes = {}
|
8
7
|
self.options = {}
|
9
8
|
self.aliases = {}
|
9
|
+
self.generators = {}
|
10
10
|
end
|
11
11
|
|
12
12
|
def default_attributes
|
13
|
-
keys = options.select { |
|
13
|
+
keys = options.select { |_key, value| value[:default] == true }.keys
|
14
14
|
keys = attributes.keys.reject { |key| options[key] && options[key][:default] == false } if keys.empty?
|
15
15
|
keys = keys.to_set
|
16
16
|
|
17
|
-
attributes.select { |key,
|
17
|
+
attributes.select { |key, _value| keys.include? key }
|
18
18
|
end
|
19
19
|
end
|
20
20
|
|
@@ -46,6 +46,10 @@ module SearchCop
|
|
46
46
|
reflection.scope = block
|
47
47
|
end
|
48
48
|
|
49
|
+
def generator(name, &block)
|
50
|
+
reflection.generators[name] = block
|
51
|
+
end
|
52
|
+
|
49
53
|
private
|
50
54
|
|
51
55
|
def attributes_hash(hash)
|
@@ -59,4 +63,3 @@ module SearchCop
|
|
59
63
|
end
|
60
64
|
end
|
61
65
|
end
|
62
|
-
|
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(" ")
|
@@ -14,11 +15,11 @@ module SearchCop
|
|
14
15
|
end
|
15
16
|
|
16
17
|
def visit_SearchCopGrammar_Nodes_And_Fulltext(node)
|
17
|
-
res = node.nodes.collect do |
|
18
|
-
if
|
19
|
-
visit
|
18
|
+
res = node.nodes.collect do |child_node|
|
19
|
+
if child_node.is_a?(SearchCopGrammar::Nodes::MatchesFulltextNot)
|
20
|
+
visit child_node
|
20
21
|
else
|
21
|
-
|
22
|
+
child_node.nodes.size > 1 ? "+(#{visit child_node})" : "+#{visit child_node}"
|
22
23
|
end
|
23
24
|
end
|
24
25
|
|
@@ -26,7 +27,7 @@ module SearchCop
|
|
26
27
|
end
|
27
28
|
|
28
29
|
def visit_SearchCopGrammar_Nodes_Or_Fulltext(node)
|
29
|
-
node.nodes.collect { |
|
30
|
+
node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" ")
|
30
31
|
end
|
31
32
|
end
|
32
33
|
|
@@ -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,31 +1,40 @@
|
|
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
|
-
"!'#{node.right.gsub
|
8
|
+
"!'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'"
|
8
9
|
end
|
9
10
|
|
10
11
|
def visit_SearchCopGrammar_Nodes_MatchesFulltext(node)
|
11
|
-
"'#{node.right.gsub
|
12
|
+
"'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'"
|
12
13
|
end
|
13
14
|
|
14
15
|
def visit_SearchCopGrammar_Nodes_And_Fulltext(node)
|
15
|
-
node.nodes.collect { |
|
16
|
+
node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" & ")
|
16
17
|
end
|
17
18
|
|
18
19
|
def visit_SearchCopGrammar_Nodes_Or_Fulltext(node)
|
19
|
-
node.nodes.collect { |
|
20
|
+
node.nodes.collect { |child_node| "(#{visit child_node})" }.join(" | ")
|
20
21
|
end
|
21
22
|
end
|
22
23
|
|
23
24
|
def visit_SearchCopGrammar_Nodes_Matches(node)
|
24
|
-
"#{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)
|
28
|
-
node.attributes.collect
|
29
|
+
res = node.attributes.collect do |attribute|
|
30
|
+
if attribute.options[:coalesce]
|
31
|
+
"COALESCE(#{visit attribute}, '')"
|
32
|
+
else
|
33
|
+
visit attribute
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
res.join(" || ' ' || ")
|
29
38
|
end
|
30
39
|
|
31
40
|
def visit_SearchCopGrammar_Nodes_FulltextExpression(node)
|
@@ -33,7 +42,8 @@ module SearchCop
|
|
33
42
|
|
34
43
|
"to_tsvector(#{visit dictionary}, #{visit node.collection}) @@ to_tsquery(#{visit dictionary}, #{visit FulltextQuery.new(connection).visit(node.node)})"
|
35
44
|
end
|
45
|
+
|
46
|
+
# rubocop:enable Naming/MethodName
|
36
47
|
end
|
37
48
|
end
|
38
49
|
end
|
39
|
-
|
@@ -1,18 +1,19 @@
|
|
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)
|
8
9
|
@connection = connection
|
9
10
|
|
10
|
-
extend(SearchCop::Visitors::Mysql) if @connection.
|
11
|
-
extend(SearchCop::Visitors::Postgres) if @connection.
|
11
|
+
extend(SearchCop::Visitors::Mysql) if @connection.adapter_name =~ /mysql/i
|
12
|
+
extend(SearchCop::Visitors::Postgres) if @connection.adapter_name =~ /postgres|postgis/i
|
12
13
|
end
|
13
14
|
|
14
15
|
def visit(visit_node = node)
|
15
|
-
send "visit_#{visit_node.class.name.gsub
|
16
|
+
send "visit_#{visit_node.class.name.gsub(/::/, "_")}", visit_node
|
16
17
|
end
|
17
18
|
|
18
19
|
def visit_SearchCopGrammar_Nodes_And(node)
|
@@ -48,13 +49,17 @@ module SearchCop
|
|
48
49
|
end
|
49
50
|
|
50
51
|
def visit_SearchCopGrammar_Nodes_Matches(node)
|
51
|
-
"#{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)
|
55
56
|
"NOT (#{visit node.object})"
|
56
57
|
end
|
57
58
|
|
59
|
+
def visit_SearchCopGrammar_Nodes_Generator(node)
|
60
|
+
instance_exec visit(node.left), node.right[:value], &node.right[:generator]
|
61
|
+
end
|
62
|
+
|
58
63
|
def quote_table_name(name)
|
59
64
|
connection.quote_table_name name
|
60
65
|
end
|
@@ -90,7 +95,9 @@ module SearchCop
|
|
90
95
|
alias :visit_Float :quote
|
91
96
|
alias :visit_Fixnum :quote
|
92
97
|
alias :visit_Symbol :quote
|
98
|
+
alias :visit_Integer :quote
|
99
|
+
|
100
|
+
# rubocop:enable Naming/MethodName
|
93
101
|
end
|
94
102
|
end
|
95
103
|
end
|
96
|
-
|