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.
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
-