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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +42 -0
  3. data/.rubocop.yml +128 -0
  4. data/CHANGELOG.md +36 -5
  5. data/CONTRIBUTING.md +18 -0
  6. data/Gemfile +4 -17
  7. data/README.md +143 -35
  8. data/Rakefile +0 -1
  9. data/docker-compose.yml +18 -0
  10. data/gemfiles/rails5.gemfile +13 -0
  11. data/gemfiles/rails6.gemfile +13 -0
  12. data/lib/search_cop.rb +15 -13
  13. data/lib/search_cop/grammar_parser.rb +3 -4
  14. data/lib/search_cop/hash_parser.rb +23 -17
  15. data/lib/search_cop/helpers.rb +15 -0
  16. data/lib/search_cop/query_builder.rb +2 -4
  17. data/lib/search_cop/query_info.rb +0 -2
  18. data/lib/search_cop/search_scope.rb +8 -5
  19. data/lib/search_cop/version.rb +1 -1
  20. data/lib/search_cop/visitors.rb +0 -2
  21. data/lib/search_cop/visitors/mysql.rb +9 -7
  22. data/lib/search_cop/visitors/postgres.rb +18 -8
  23. data/lib/search_cop/visitors/visitor.rb +13 -6
  24. data/lib/search_cop_grammar.rb +18 -9
  25. data/lib/search_cop_grammar.treetop +6 -4
  26. data/lib/search_cop_grammar/attributes.rb +77 -34
  27. data/lib/search_cop_grammar/nodes.rb +7 -2
  28. data/search_cop.gemspec +9 -10
  29. data/test/and_test.rb +6 -8
  30. data/test/boolean_test.rb +7 -9
  31. data/test/database.yml +4 -1
  32. data/test/date_test.rb +38 -12
  33. data/test/datetime_test.rb +45 -12
  34. data/test/default_operator_test.rb +51 -0
  35. data/test/error_test.rb +2 -4
  36. data/test/float_test.rb +16 -11
  37. data/test/fulltext_test.rb +8 -10
  38. data/test/hash_test.rb +39 -31
  39. data/test/integer_test.rb +9 -11
  40. data/test/not_test.rb +6 -8
  41. data/test/or_test.rb +8 -10
  42. data/test/scope_test.rb +11 -13
  43. data/test/search_cop_test.rb +36 -34
  44. data/test/string_test.rb +67 -19
  45. data/test/test_helper.rb +24 -18
  46. data/test/visitor_test.rb +15 -8
  47. metadata +41 -55
  48. data/.travis.yml +0 -34
  49. data/Appraisals +0 -20
  50. data/gemfiles/3.2.gemfile +0 -26
  51. data/gemfiles/4.0.gemfile +0 -26
  52. data/gemfiles/4.1.gemfile +0 -26
  53. data/gemfiles/4.2.gemfile +0 -26
data/Rakefile CHANGED
@@ -6,4 +6,3 @@ Rake::TestTask.new(:test) do |t|
6
6
  t.pattern = "test/**/*_test.rb"
7
7
  t.verbose = true
8
8
  end
9
-
@@ -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
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.1"
6
+
7
+ platforms :ruby do
8
+ gem "mysql2"
9
+ gem "pg"
10
+ gem "sqlite3"
11
+ end
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.0"
6
+
7
+ platforms :ruby do
8
+ gem "mysql2"
9
+ gem "pg"
10
+ gem "sqlite3"
11
+ end
12
+
13
+ gemspec path: "../"
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 SpecificationError < StandardError; end
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 < StandardError; end
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
- self.class.send(:define_method, name) { |query| search_cop query, name }
45
- self.class.send(:define_method, "unsafe_#{name}") { |query| unsafe_search_cop query, name }
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 query, scope_name
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("../../search_cop_grammar.treetop", __FILE__)
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
- when :and
13
- value.collect { |val| parse val }.inject(:and)
14
- when :or
15
- value.collect { |val| parse val }.inject(:or)
16
- when :not
17
- parse(value).not
18
- when :query
19
- SearchCop::Parser.parse value, query_info
20
- else
21
- parse_attribute key, value
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 :and
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 [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].include?(value.keys.first)
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
- collection.send value.keys.first, value.values.first.to_s
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 :matches, value.to_s
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,4 +1,3 @@
1
-
2
1
  module SearchCop
3
2
  class QueryInfo
4
3
  attr_accessor :model, :scope, :references
@@ -10,4 +9,3 @@ module SearchCop
10
9
  end
11
10
  end
12
11
  end
13
-
@@ -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 { |key, value| value[:default] == true }.keys
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, value| keys.include? 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
-
@@ -1,3 +1,3 @@
1
1
  module SearchCop
2
- VERSION = "1.0.6"
2
+ VERSION = "1.2.0"
3
3
  end
@@ -1,5 +1,3 @@
1
-
2
1
  require "search_cop/visitors/visitor"
3
2
  require "search_cop/visitors/mysql"
4
3
  require "search_cop/visitors/postgres"
5
-
@@ -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 |node|
18
- if node.is_a?(SearchCopGrammar::Nodes::MatchesFulltextNot)
19
- visit node
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
- node.nodes.size > 1 ? "+(#{visit node})" : "+#{visit node}"
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 { |node| "(#{visit node})" }.join(" ")
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 /[\s&|!:'"]+/, " "}'"
8
+ "!'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'"
8
9
  end
9
10
 
10
11
  def visit_SearchCopGrammar_Nodes_MatchesFulltext(node)
11
- "'#{node.right.gsub /[\s&|!:'"]+/, " "}'"
12
+ "'#{node.right.gsub(/[\s&|!:'"]+/, " ")}'"
12
13
  end
13
14
 
14
15
  def visit_SearchCopGrammar_Nodes_And_Fulltext(node)
15
- node.nodes.collect { |node| "(#{visit node})" }.join(" & ")
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 { |node| "(#{visit node})" }.join(" | ")
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 { |attribute| visit attribute }.join(" || ' ' || ")
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.class.name =~ /mysql/i
11
- extend(SearchCop::Visitors::Postgres) if @connection.class.name =~ /postgres/i
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 /::/, "_"}", visit_node
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
-