search_cop 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,183 @@
1
+
2
+ require "treetop"
3
+
4
+ module SearchCopGrammar
5
+ module Nodes
6
+ module Base
7
+ def and(node)
8
+ And.new self, node
9
+ end
10
+
11
+ def or(node)
12
+ Or.new self, node
13
+ end
14
+
15
+ def not
16
+ Not.new self
17
+ end
18
+
19
+ def can_flatten?
20
+ false
21
+ end
22
+
23
+ def flatten!
24
+ self
25
+ end
26
+
27
+ def can_group?
28
+ false
29
+ end
30
+
31
+ def group!
32
+ self
33
+ end
34
+
35
+ def fulltext?
36
+ false
37
+ end
38
+
39
+ def can_optimize?
40
+ can_flatten? || can_group?
41
+ end
42
+
43
+ def optimize!
44
+ flatten!.group! while can_optimize?
45
+
46
+ finalize!
47
+ end
48
+
49
+ def finalize!
50
+ self
51
+ end
52
+
53
+ def nodes
54
+ []
55
+ end
56
+ end
57
+
58
+ class Binary
59
+ include Base
60
+
61
+ attr_accessor :left, :right
62
+
63
+ def initialize(left, right)
64
+ @left = left
65
+ @right = right
66
+ end
67
+ end
68
+
69
+ class Equality < Binary; end
70
+ class NotEqual < Binary; end
71
+ class GreaterThan < Binary; end
72
+ class GreaterThanOrEqual < Binary; end
73
+ class LessThan < Binary; end
74
+ class LessThanOrEqual < Binary; end
75
+ class Matches < Binary; end
76
+
77
+ class Not
78
+ include Base
79
+
80
+ attr_accessor :object
81
+
82
+ def initialize(object)
83
+ @object = object
84
+ end
85
+ end
86
+
87
+ class MatchesFulltext < Binary
88
+ include Base
89
+
90
+ def not
91
+ MatchesFulltextNot.new left, right
92
+ end
93
+
94
+ def fulltext?
95
+ true
96
+ end
97
+
98
+ def finalize!
99
+ FulltextExpression.new collection, self
100
+ end
101
+
102
+ def collection
103
+ left
104
+ end
105
+ end
106
+
107
+ class MatchesFulltextNot < MatchesFulltext; end
108
+
109
+ class FulltextExpression
110
+ include Base
111
+
112
+ attr_reader :collection, :node
113
+
114
+ def initialize(collection, node)
115
+ @collection = collection
116
+ @node = node
117
+ end
118
+ end
119
+
120
+ class Collection
121
+ include Base
122
+
123
+ attr_reader :nodes
124
+
125
+ def initialize(*nodes)
126
+ @nodes = nodes.flatten
127
+ end
128
+
129
+ def can_flatten?
130
+ nodes.any?(&:can_flatten?) || nodes.any? { |node| node.is_a?(self.class) || node.nodes.size == 1 }
131
+ end
132
+
133
+ def flatten!(&block)
134
+ @nodes = nodes.collect(&:flatten!).collect { |node| node.is_a?(self.class) || node.nodes.size == 1 ? node.nodes : node }.flatten
135
+
136
+ self
137
+ end
138
+
139
+ def can_group?
140
+ nodes.reject(&:fulltext?).any?(&:can_group?) || nodes.select(&:fulltext?).group_by(&:collection).any? { |_, group| group.size > 1 }
141
+ end
142
+
143
+ def group!
144
+ @nodes = nodes.reject(&:fulltext?).collect(&:group!) + nodes.select(&:fulltext?).group_by(&:collection).collect { |collection, group| group.size > 1 ? self.class::Fulltext.new(collection, group) : group.first }
145
+
146
+ self
147
+ end
148
+
149
+ def finalize!
150
+ @nodes = nodes.collect(&:finalize!)
151
+
152
+ self
153
+ end
154
+ end
155
+
156
+ class FulltextCollection < Collection
157
+ attr_reader :collection
158
+
159
+ def initialize(collection, *nodes)
160
+ @collection = collection
161
+
162
+ super *nodes
163
+ end
164
+
165
+ def fulltext?
166
+ true
167
+ end
168
+
169
+ def finalize!
170
+ FulltextExpression.new collection, self
171
+ end
172
+ end
173
+
174
+ class And < Collection
175
+ class Fulltext < FulltextCollection; end
176
+ end
177
+
178
+ class Or < Collection
179
+ class Fulltext < FulltextCollection; end
180
+ end
181
+ end
182
+ end
183
+
@@ -0,0 +1,133 @@
1
+
2
+ require "search_cop_grammar/attributes"
3
+ require "search_cop_grammar/nodes"
4
+
5
+ module SearchCopGrammar
6
+ class BaseNode < Treetop::Runtime::SyntaxNode
7
+ attr_accessor :query_info
8
+
9
+ def query_info
10
+ @query_info || parent.query_info
11
+ end
12
+
13
+ def evaluate
14
+ elements.collect(&:evaluate).inject(:and)
15
+ end
16
+
17
+ def elements
18
+ super.select { |element| element.class != Treetop::Runtime::SyntaxNode }
19
+ end
20
+
21
+ def collection_for(key)
22
+ raise(SearchCop::UnknownColumn, "Unknown column #{key}") if query_info.scope.reflection.attributes[key].nil?
23
+
24
+ Attributes::Collection.new query_info, key
25
+ end
26
+ end
27
+
28
+ class OperatorNode < Treetop::Runtime::SyntaxNode
29
+ def evaluate
30
+ text_value
31
+ end
32
+ end
33
+
34
+ class ComplexExpression < BaseNode; end
35
+ class ParenthesesExpression < BaseNode; end
36
+
37
+ class ComparativeExpression < BaseNode
38
+ def evaluate
39
+ elements[0].collection.send elements[1].method_name, elements[2].text_value
40
+ end
41
+ end
42
+
43
+ class IncludesOperator < OperatorNode
44
+ def method_name
45
+ :matches
46
+ end
47
+ end
48
+
49
+ class EqualOperator < OperatorNode
50
+ def method_name
51
+ :eq
52
+ end
53
+ end
54
+
55
+ class UnequalOperator < OperatorNode
56
+ def method_name
57
+ :not_eq
58
+ end
59
+ end
60
+
61
+ class GreaterEqualOperator < OperatorNode
62
+ def method_name
63
+ :gteq
64
+ end
65
+ end
66
+
67
+ class GreaterOperator < OperatorNode
68
+ def method_name
69
+ :gt
70
+ end
71
+ end
72
+
73
+ class LessEqualOperator < OperatorNode
74
+ def method_name
75
+ :lteq
76
+ end
77
+ end
78
+
79
+ class LessOperator < OperatorNode
80
+ def method_name
81
+ :lt
82
+ end
83
+ end
84
+
85
+ class AnywhereExpression < BaseNode
86
+ def evaluate
87
+ queries = query_info.scope.reflection.default_attributes.keys.collect { |key| collection_for key }.select { |collection| collection.compatible? text_value }.collect { |collection| collection.matches text_value }
88
+
89
+ raise SearchCop::NoSearchableAttributes if queries.empty?
90
+
91
+ queries.flatten.inject(:or)
92
+ end
93
+ end
94
+
95
+ class AndExpression < BaseNode
96
+ def evaluate
97
+ [elements.first.evaluate, elements.last.evaluate].inject(:and)
98
+ end
99
+ end
100
+
101
+ class OrExpression < BaseNode
102
+ def evaluate
103
+ [elements.first.evaluate, elements.last.evaluate].inject(:or)
104
+ end
105
+ end
106
+
107
+ class NotExpression < BaseNode
108
+ def evaluate
109
+ elements.first.evaluate.not
110
+ end
111
+ end
112
+
113
+ class Column < BaseNode
114
+ def collection
115
+ collection_for text_value
116
+ end
117
+ end
118
+
119
+ class SingleQuotedValue < BaseNode
120
+ def text_value
121
+ super.gsub /^'|'$/, ""
122
+ end
123
+ end
124
+
125
+ class DoubleQuotedValue < BaseNode
126
+ def text_value
127
+ super.gsub /^"|"$/, ""
128
+ end
129
+ end
130
+
131
+ class Value < BaseNode; end
132
+ end
133
+
@@ -0,0 +1,55 @@
1
+
2
+ grammar SearchCopGrammar
3
+ rule complex_expression
4
+ space? (boolean_expression / expression) space? <ComplexExpression>
5
+ end
6
+
7
+ rule boolean_expression
8
+ and_expression
9
+ end
10
+
11
+ rule and_expression
12
+ or_expression (space? ('AND' / 'and') space? / space !('OR' / 'or')) complex_expression <AndExpression> / or_expression
13
+ end
14
+
15
+ rule or_expression
16
+ expression space? ('OR' / 'or') space? (or_expression / expression) <OrExpression> / expression
17
+ end
18
+
19
+ rule expression
20
+ parentheses_expression / not_expression / comparative_expression / anywhere_expression
21
+ end
22
+
23
+ rule parentheses_expression
24
+ '(' complex_expression ')' <ParenthesesExpression>
25
+ end
26
+
27
+ rule not_expression
28
+ ('NOT' space / 'not' space / '-') (comparative_expression / anywhere_expression) <NotExpression>
29
+ end
30
+
31
+ rule comparative_expression
32
+ simple_column space? comparison_operator space? value <ComparativeExpression>
33
+ end
34
+
35
+ rule comparison_operator
36
+ ':' <IncludesOperator> / '=' <EqualOperator> / '!=' <UnequalOperator> / '>=' <GreaterEqualOperator> / '>' <GreaterOperator> / '<=' <LessEqualOperator> / '<' <LessOperator>
37
+ end
38
+
39
+ rule anywhere_expression
40
+ ("'" ([^\']* <AnywhereExpression>) "'") / ('"' ([^\"]* <AnywhereExpression>) '"') / [^\s()]+ <AnywhereExpression>
41
+ end
42
+
43
+ rule simple_column
44
+ [a-zA-Z0-9_.]+ <Column>
45
+ end
46
+
47
+ rule value
48
+ "'" [^\']* "'" <SingleQuotedValue> / '"' [^\"]* '"' <DoubleQuotedValue> / [^\s()]+ <Value>
49
+ end
50
+
51
+ rule space
52
+ [\s]+
53
+ end
54
+ end
55
+
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'search_cop/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "search_cop"
8
+ spec.version = SearchCop::VERSION
9
+ spec.authors = ["Benjamin Vetter"]
10
+ 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}
13
+ spec.homepage = "https://github.com/mrkamel/search_cop"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "treetop"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.3"
24
+ spec.add_development_dependency "rake"
25
+ spec.add_development_dependency "activerecord", ">= 3.0.0"
26
+ spec.add_development_dependency "factory_girl"
27
+ spec.add_development_dependency "appraisal"
28
+ spec.add_development_dependency "minitest"
29
+ end
data/test/and_test.rb ADDED
@@ -0,0 +1,27 @@
1
+
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ class AndTest < SearchCop::TestCase
5
+ def test_and_string
6
+ expected = create(:product, :title => "Expected title", :description => "Description")
7
+ rejected = create(:product, :title => "Rejected title", :description => "Description")
8
+
9
+ results = Product.search("title: 'Expected title' description: Description")
10
+
11
+ assert_includes results, expected
12
+ refute_includes results, rejected
13
+
14
+ assert_equal results, Product.search("title: 'Expected title' AND description: Description")
15
+ end
16
+
17
+ def test_and_hash
18
+ expected = create(:product, :title => "Expected title", :description => "Description")
19
+ rejected = create(:product, :title => "Rejected title", :description => "Description")
20
+
21
+ results = Product.search(:and => [{:title => "Expected title"}, {:description => "Description"}])
22
+
23
+ assert_includes results, expected
24
+ refute_includes results, rejected
25
+ end
26
+ end
27
+
@@ -0,0 +1,53 @@
1
+
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ class BooleanTest < SearchCop::TestCase
5
+ def test_mapping
6
+ product = create(:product, :available => true)
7
+
8
+ assert_includes Product.search("available: 1"), product
9
+ assert_includes Product.search("available: true"), product
10
+ assert_includes Product.search("available: yes"), product
11
+
12
+ product = create(:product, :available => false)
13
+
14
+ assert_includes Product.search("available: 0"), product
15
+ assert_includes Product.search("available: false"), product
16
+ assert_includes Product.search("available: no"), product
17
+ end
18
+
19
+ def test_anywhere
20
+ product = create(:product, :available => true)
21
+
22
+ assert_includes Product.search("true"), product
23
+ refute_includes Product.search("false"), product
24
+ end
25
+
26
+ def test_includes
27
+ product = create(:product, :available => true)
28
+
29
+ assert_includes Product.search("available: true"), product
30
+ refute_includes Product.search("available: false"), product
31
+ end
32
+
33
+ def test_equals
34
+ product = create(:product, :available => true)
35
+
36
+ assert_includes Product.search("available = true"), product
37
+ refute_includes Product.search("available = false"), product
38
+ end
39
+
40
+ def test_equals_not
41
+ product = create(:product, :available => false)
42
+
43
+ assert_includes Product.search("available != true"), product
44
+ refute_includes Product.search("available != false"), product
45
+ end
46
+
47
+ def test_incompatible_datatype
48
+ assert_raises SearchCop::IncompatibleDatatype do
49
+ Product.unsafe_search "available: Value"
50
+ end
51
+ end
52
+ end
53
+
data/test/database.yml ADDED
@@ -0,0 +1,17 @@
1
+
2
+ sqlite:
3
+ adapter: sqlite3
4
+ database: ":memory:"
5
+
6
+ mysql:
7
+ adapter: mysql2
8
+ database: search_cop
9
+ username: root
10
+ encoding: utf8
11
+
12
+ postgres:
13
+ adapter: postgresql
14
+ database: search_cop
15
+ username: postgres
16
+ encoding: utf8
17
+
data/test/date_test.rb ADDED
@@ -0,0 +1,75 @@
1
+
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ class DateTest < SearchCop::TestCase
5
+ def test_mapping
6
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
7
+
8
+ assert_includes Product.search("created_on: 2014"), product
9
+ assert_includes Product.search("created_on: 2014-05"), product
10
+ assert_includes Product.search("created_on: 2014-05-01"), product
11
+ end
12
+
13
+ def test_anywhere
14
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
15
+
16
+ assert_includes Product.search("2014-05-01"), product
17
+ refute_includes Product.search("2014-05-02"), product
18
+ end
19
+
20
+ def test_includes
21
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
22
+
23
+ assert_includes Product.search("created_on: 2014-05-01"), product
24
+ refute_includes Product.search("created_on: 2014-05-02"), product
25
+ end
26
+
27
+ def test_equals
28
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
29
+
30
+ assert_includes Product.search("created_on = 2014-05-01"), product
31
+ refute_includes Product.search("created_on = 2014-05-02"), product
32
+ end
33
+
34
+ def test_equals_not
35
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
36
+
37
+ assert_includes Product.search("created_on != 2014-05-02"), product
38
+ refute_includes Product.search("created_on != 2014-05-01"), product
39
+ end
40
+
41
+ def test_greater
42
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
43
+
44
+ assert_includes Product.search("created_on > 2014-04-01"), product
45
+ refute_includes Product.search("created_on > 2014-05-01"), product
46
+ end
47
+
48
+ def test_greater_equals
49
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
50
+
51
+ assert_includes Product.search("created_on >= 2014-05-01"), product
52
+ refute_includes Product.search("created_on >= 2014-05-02"), product
53
+ end
54
+
55
+ def test_less
56
+ product = create(:product, :created_on => Date.parse("2014-05-01"))
57
+
58
+ assert_includes Product.search("created_on < 2014-05-02"), product
59
+ refute_includes Product.search("created_on < 2014-05-01"), product
60
+ end
61
+
62
+ def test_less_equals
63
+ product = create(:product, :created_on => Date.parse("2014-05-02"))
64
+
65
+ assert_includes Product.search("created_on <= 2014-05-02"), product
66
+ refute_includes Product.search("created_on <= 2014-05-01"), product
67
+ end
68
+
69
+ def test_incompatible_datatype
70
+ assert_raises SearchCop::IncompatibleDatatype do
71
+ Product.unsafe_search "created_on: Value"
72
+ end
73
+ end
74
+ end
75
+
@@ -0,0 +1,76 @@
1
+
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ class DatetimeTest < SearchCop::TestCase
5
+ def test_mapping
6
+ product = create(:product, :created_at => Time.parse("2014-05-01 12:30:30"))
7
+
8
+ assert_includes Product.search("created_at: 2014"), product
9
+ assert_includes Product.search("created_at: 2014-05"), product
10
+ assert_includes Product.search("created_at: 2014-05-01"), product
11
+ assert_includes Product.search("created_at: '2014-05-01 12:30:30'"), product
12
+ end
13
+
14
+ def test_anywhere
15
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
16
+
17
+ assert_includes Product.search("2014-05-01"), product
18
+ refute_includes Product.search("2014-05-02"), product
19
+ end
20
+
21
+ def test_includes
22
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
23
+
24
+ assert_includes Product.search("created_at: 2014-05-01"), product
25
+ refute_includes Product.search("created_at: 2014-05-02"), product
26
+ end
27
+
28
+ def test_equals
29
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
30
+
31
+ assert_includes Product.search("created_at = 2014-05-01"), product
32
+ refute_includes Product.search("created_at = 2014-05-02"), product
33
+ end
34
+
35
+ def test_equals_not
36
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
37
+
38
+ assert_includes Product.search("created_at != 2014-05-02"), product
39
+ refute_includes Product.search("created_at != 2014-05-01"), product
40
+ end
41
+
42
+ def test_greater
43
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
44
+
45
+ assert_includes Product.search("created_at > 2014-04-01"), product
46
+ refute_includes Product.search("created_at > 2014-05-01"), product
47
+ end
48
+
49
+ def test_greater_equals
50
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
51
+
52
+ assert_includes Product.search("created_at >= 2014-05-01"), product
53
+ refute_includes Product.search("created_at >= 2014-05-02"), product
54
+ end
55
+
56
+ def test_less
57
+ product = create(:product, :created_at => Time.parse("2014-05-01"))
58
+
59
+ assert_includes Product.search("created_at < 2014-05-02"), product
60
+ refute_includes Product.search("created_at < 2014-05-01"), product
61
+ end
62
+
63
+ def test_less_equals
64
+ product = create(:product, :created_at => Time.parse("2014-05-02"))
65
+
66
+ assert_includes Product.search("created_at <= 2014-05-02"), product
67
+ refute_includes Product.search("created_at <= 2014-05-01"), product
68
+ end
69
+
70
+ def test_incompatible_datatype
71
+ assert_raises SearchCop::IncompatibleDatatype do
72
+ Product.unsafe_search "created_at: Value"
73
+ end
74
+ end
75
+ end
76
+
@@ -0,0 +1,17 @@
1
+
2
+ require File.expand_path("../test_helper", __FILE__)
3
+
4
+ class ErrorTest < SearchCop::TestCase
5
+ def test_parse_error
6
+ assert_raises SearchCop::ParseError do
7
+ Product.unsafe_search :title => { :unknown_operator => "Value" }
8
+ end
9
+ end
10
+
11
+ def test_unknown_column
12
+ assert_raises SearchCop::UnknownColumn do
13
+ Product.unsafe_search "Unknown: Column"
14
+ end
15
+ end
16
+ end
17
+