search_cop 1.0.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.
@@ -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
+