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