search_cop 1.0.6 → 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 +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
|
-
|