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
@@ -1,13 +1,16 @@
1
-
2
1
  require "search_cop_grammar/attributes"
3
2
  require "search_cop_grammar/nodes"
4
3
 
5
4
  module SearchCopGrammar
6
5
  class BaseNode < Treetop::Runtime::SyntaxNode
7
- attr_accessor :query_info
6
+ attr_writer :query_info, :query_options
8
7
 
9
8
  def query_info
10
- @query_info || parent.query_info
9
+ (@query_info ||= nil) || parent.query_info
10
+ end
11
+
12
+ def query_options
13
+ (@query_options ||= nil) || parent.query_options
11
14
  end
12
15
 
13
16
  def evaluate
@@ -15,7 +18,7 @@ module SearchCopGrammar
15
18
  end
16
19
 
17
20
  def elements
18
- super.select { |element| element.class != Treetop::Runtime::SyntaxNode }
21
+ super.reject { |element| element.instance_of?(Treetop::Runtime::SyntaxNode) }
19
22
  end
20
23
 
21
24
  def collection_for(key)
@@ -94,13 +97,13 @@ module SearchCopGrammar
94
97
 
95
98
  class SingleQuotedAnywhereExpression < AnywhereExpression
96
99
  def text_value
97
- super.gsub /^'|'$/, ""
100
+ super.gsub(/^'|'$/, "")
98
101
  end
99
102
  end
100
103
 
101
104
  class DoubleQuotedAnywhereExpression < AnywhereExpression
102
105
  def text_value
103
- super.gsub /^"|"$/, ""
106
+ super.gsub(/^"|"$/, "")
104
107
  end
105
108
  end
106
109
 
@@ -110,6 +113,13 @@ module SearchCopGrammar
110
113
  end
111
114
  end
112
115
 
116
+ class AndOrExpression < BaseNode
117
+ def evaluate
118
+ default_operator = SearchCop::Helpers.sanitize_default_operator(query_options)
119
+ [elements.first.evaluate, elements.last.evaluate].inject(default_operator)
120
+ end
121
+ end
122
+
113
123
  class OrExpression < BaseNode
114
124
  def evaluate
115
125
  [elements.first.evaluate, elements.last.evaluate].inject(:or)
@@ -130,16 +140,15 @@ module SearchCopGrammar
130
140
 
131
141
  class SingleQuotedValue < BaseNode
132
142
  def text_value
133
- super.gsub /^'|'$/, ""
143
+ super.gsub(/^'|'$/, "")
134
144
  end
135
145
  end
136
146
 
137
147
  class DoubleQuotedValue < BaseNode
138
148
  def text_value
139
- super.gsub /^"|"$/, ""
149
+ super.gsub(/^"|"$/, "")
140
150
  end
141
151
  end
142
152
 
143
153
  class Value < BaseNode; end
144
154
  end
145
-
@@ -9,7 +9,9 @@ grammar SearchCopGrammar
9
9
  end
10
10
 
11
11
  rule and_expression
12
- or_expression (space? ('AND' / 'and') space? / space !('OR' / 'or')) complex_expression <AndExpression> / or_expression
12
+ or_expression space? ('AND' / 'and') space? complex_expression <AndExpression> /
13
+ or_expression space !('OR' / 'or') complex_expression <AndOrExpression> /
14
+ or_expression
13
15
  end
14
16
 
15
17
  rule or_expression
@@ -37,7 +39,7 @@ grammar SearchCopGrammar
37
39
  end
38
40
 
39
41
  rule anywhere_expression
40
- "'" [^\']* "'" <SingleQuotedAnywhereExpression> / '"' [^\"]* '"' <DoubleQuotedAnywhereExpression> / [^\s()]+ <AnywhereExpression>
42
+ "'" [^\']* "'" <SingleQuotedAnywhereExpression> / '"' [^\"]* '"' <DoubleQuotedAnywhereExpression> / [^[:blank:]()]+ <AnywhereExpression>
41
43
  end
42
44
 
43
45
  rule simple_column
@@ -45,11 +47,11 @@ grammar SearchCopGrammar
45
47
  end
46
48
 
47
49
  rule value
48
- "'" [^\']* "'" <SingleQuotedValue> / '"' [^\"]* '"' <DoubleQuotedValue> / [^\s()]+ <Value>
50
+ "'" [^\']* "'" <SingleQuotedValue> / '"' [^\"]* '"' <DoubleQuotedValue> / [^[:blank:]()]+ <Value>
49
51
  end
50
52
 
51
53
  rule space
52
- [\s]+
54
+ [[:blank:]]+
53
55
  end
54
56
  end
55
57
 
@@ -1,4 +1,3 @@
1
-
2
1
  require "treetop"
3
2
 
4
3
  module SearchCopGrammar
@@ -6,6 +5,8 @@ module SearchCopGrammar
6
5
  class Collection
7
6
  attr_reader :query_info, :key
8
7
 
8
+ INCLUDED_OPERATORS = [:matches, :eq, :not_eq, :gt, :gteq, :lt, :lteq].freeze
9
+
9
10
  def initialize(query_info, key)
10
11
  raise(SearchCop::UnknownColumn, "Unknown column #{key}") unless query_info.scope.reflection.attributes[key]
11
12
 
@@ -26,9 +27,15 @@ module SearchCopGrammar
26
27
  end
27
28
 
28
29
  [:eq, :not_eq, :lt, :lteq, :gt, :gteq].each do |method|
29
- define_method method do |value|
30
- attributes.collect! { |attribute| attribute.send method, value }.inject(:or)
31
- end
30
+ define_method method do |value|
31
+ attributes.collect! { |attribute| attribute.send method, value }.inject(:or)
32
+ end
33
+ end
34
+
35
+ def generator(generator, value)
36
+ attributes.collect! do |attribute|
37
+ SearchCopGrammar::Nodes::Generator.new(attribute, generator: generator, value: value)
38
+ end.inject(:or)
32
39
  end
33
40
 
34
41
  def matches(value)
@@ -88,6 +95,18 @@ module SearchCopGrammar
88
95
 
89
96
  Attributes.const_get(klass.columns_hash[column].type.to_s.classify).new(klass, alias_for(table), column, options)
90
97
  end
98
+
99
+ def generator_for(name)
100
+ generators[name]
101
+ end
102
+
103
+ def valid_operator?(operator)
104
+ (INCLUDED_OPERATORS + generators.keys).include?(operator)
105
+ end
106
+
107
+ def generators
108
+ query_info.scope.reflection.generators
109
+ end
91
110
  end
92
111
 
93
112
  class Base
@@ -117,7 +136,7 @@ module SearchCopGrammar
117
136
  false
118
137
  end
119
138
 
120
- { :eq => "Equality", :not_eq => "NotEqual", :lt => "LessThan", :lteq => "LessThanOrEqual", :gt => "GreaterThan", :gteq => "GreaterThanOrEqual", :matches => "Matches" }.each do |method, class_name|
139
+ { eq: "Equality", not_eq: "NotEqual", lt: "LessThan", lteq: "LessThanOrEqual", gt: "GreaterThan", gteq: "GreaterThanOrEqual", matches: "Matches" }.each do |method, class_name|
121
140
  define_method method do |value|
122
141
  raise(SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}") unless compatible?(value)
123
142
 
@@ -126,19 +145,32 @@ module SearchCopGrammar
126
145
  end
127
146
 
128
147
  def method_missing(name, *args, &block)
129
- @attribute.send(name, *args, &block)
148
+ if @attribute.respond_to?(name)
149
+ @attribute.send(name, *args, &block)
150
+ else
151
+ super
152
+ end
130
153
  end
131
154
 
132
- def respond_to?(*args)
133
- super(*args) || @attribute.respond_to?(*args)
155
+ def respond_to_missing?(*args)
156
+ @attribute.respond_to?(*args) || super
134
157
  end
135
158
  end
136
159
 
137
160
  class String < Base
138
161
  def matches_value(value)
139
- return value.gsub(/\*/, "%") if (options[:left_wildcard] != false && value.strip =~ /^[^*]+\*$|^\*[^*]+$/) || value.strip =~ /^[^*]+\*$/
162
+ res = value.gsub(/[%_\\]/) { |char| "\\#{char}" }
163
+
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
140
170
 
141
- options[:left_wildcard] != false ? "%#{value}%" : "#{value}%"
171
+ res = "%#{res}" if options[:left_wildcard] != false
172
+ res = "#{res}%" if options[:right_wildcard] != false
173
+ res
142
174
  end
143
175
 
144
176
  def matches(value)
@@ -156,7 +188,7 @@ module SearchCopGrammar
156
188
 
157
189
  class Float < WithoutMatches
158
190
  def compatible?(value)
159
- return true if value.to_s =~ /^[0-9]+(\.[0-9]+)?$/
191
+ return true if value.to_s =~ /^-?[0-9]+(\.[0-9]+)?$/
160
192
 
161
193
  false
162
194
  end
@@ -176,20 +208,26 @@ module SearchCopGrammar
176
208
 
177
209
  class Datetime < WithoutMatches
178
210
  def parse(value)
179
- return value .. value unless value.is_a?(::String)
180
-
181
- if value =~ /^[0-9]{4}$/
182
- ::Time.new(value).beginning_of_year .. ::Time.new(value).end_of_year
183
- elsif value =~ /^([0-9]{4})(\.|-|\/)([0-9]{1,2})$/
184
- ::Time.new($1, $3, 15).beginning_of_month .. ::Time.new($1, $3, 15).end_of_month
185
- elsif value =~ /^([0-9]{1,2})(\.|-|\/)([0-9]{4})$/
186
- ::Time.new($3, $1, 15).beginning_of_month .. ::Time.new($3, $1, 15).end_of_month
187
- elsif value !~ /:/
211
+ return value..value unless value.is_a?(::String)
212
+
213
+ if value =~ /^[0-9]+ (hour|day|week|month|year)s{0,1} (ago)$/
214
+ number, period, ago = value.split(" ")
215
+ time = number.to_i.send(period.to_sym).send(ago.to_sym)
216
+ time..::Time.now
217
+ elsif value =~ /^[0-9]{4}$/
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}$}
188
224
  time = ::Time.parse(value)
189
- time.beginning_of_day .. time.end_of_day
190
- else
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}}
191
227
  time = ::Time.parse(value)
192
- time .. time
228
+ time..time
229
+ else
230
+ raise ArgumentError
193
231
  end
194
232
  rescue ArgumentError
195
233
  raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}"
@@ -220,17 +258,23 @@ module SearchCopGrammar
220
258
 
221
259
  class Date < Datetime
222
260
  def parse(value)
223
- return value .. value unless value.is_a?(::String)
224
-
225
- if value =~ /^[0-9]{4}$/
226
- ::Date.new(value.to_i).beginning_of_year .. ::Date.new(value.to_i).end_of_year
227
- elsif value =~ /^([0-9]{4})(\.|-|\/)([0-9]{1,2})$/
228
- ::Date.new($1.to_i, $3.to_i, 15).beginning_of_month .. ::Date.new($1.to_i, $3.to_i, 15).end_of_month
229
- elsif value =~ /^([0-9]{1,2})(\.|-|\/)([0-9]{4})$/
230
- ::Date.new($3.to_i, $1.to_i, 15).beginning_of_month .. ::Date.new($3.to_i, $1.to_i, 15).end_of_month
231
- else
261
+ return value..value unless value.is_a?(::String)
262
+
263
+ if value =~ /^[0-9]+ (day|week|month|year)s{0,1} (ago)$/
264
+ number, period, ago = value.split(" ")
265
+ time = number.to_i.send(period.to_sym).send(ago.to_sym)
266
+ time.to_date..::Date.today
267
+ elsif value =~ /^[0-9]{4}$/
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}}
232
274
  date = ::Date.parse(value)
233
- date .. date
275
+ date..date
276
+ else
277
+ raise ArgumentError
234
278
  end
235
279
  rescue ArgumentError
236
280
  raise SearchCop::IncompatibleDatatype, "Incompatible datatype for #{value}"
@@ -249,4 +293,3 @@ module SearchCopGrammar
249
293
  end
250
294
  end
251
295
  end
252
-
@@ -1,4 +1,3 @@
1
-
2
1
  require "treetop"
3
2
 
4
3
  module SearchCopGrammar
@@ -73,6 +72,7 @@ module SearchCopGrammar
73
72
  class LessThan < Binary; end
74
73
  class LessThanOrEqual < Binary; end
75
74
  class Matches < Binary; end
75
+ class Generator < Binary; end
76
76
 
77
77
  class Not
78
78
  include Base
@@ -82,6 +82,12 @@ module SearchCopGrammar
82
82
  def initialize(object)
83
83
  @object = object
84
84
  end
85
+
86
+ def finalize!
87
+ @object.finalize!
88
+
89
+ self
90
+ end
85
91
  end
86
92
 
87
93
  class MatchesFulltext < Binary
@@ -180,4 +186,3 @@ module SearchCopGrammar
180
186
  end
181
187
  end
182
188
  end
183
-
data/search_cop.gemspec CHANGED
@@ -1,29 +1,28 @@
1
- # coding: utf-8
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 'search_cop/version'
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 = %q{Search engine like fulltext query support for ActiveRecord}
12
- spec.summary = %q{Easily perform complex search engine like fulltext queries on your ActiveRecord models}
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", "~> 1.3"
24
- spec.add_development_dependency "rake"
25
22
  spec.add_development_dependency "activerecord", ">= 3.0.0"
26
- spec.add_development_dependency "factory_girl"
27
- spec.add_development_dependency "appraisal"
23
+ spec.add_development_dependency "bundler"
24
+ spec.add_development_dependency "factory_bot"
28
25
  spec.add_development_dependency "minitest"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "rubocop"
29
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, :title => "Expected title", :description => "Description")
7
- rejected = create(:product, :title => "Rejected title", :description => "Description")
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, :title => "Expected title", :description => "Description")
19
- rejected = create(:product, :title => "Rejected title", :description => "Description")
17
+ expected = create(:product, title: "Expected title", description: "Description")
18
+ rejected = create(:product, title: "Rejected title", description: "Description")
20
19
 
21
- results = Product.search(:and => [{:title => "Expected title"}, {:description => "Description"}])
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, :available => true)
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, :available => false)
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, :available => true)
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, :available => true)
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, :available => true)
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, :available => false)
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
-