scoped_search 2.0.1
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.
- data/.gitignore +8 -0
- data/LICENSE +20 -0
- data/README.rdoc +107 -0
- data/Rakefile +4 -0
- data/init.rb +1 -0
- data/lib/scoped_search.rb +91 -0
- data/lib/scoped_search/definition.rb +145 -0
- data/lib/scoped_search/query_builder.rb +296 -0
- data/lib/scoped_search/query_language.rb +38 -0
- data/lib/scoped_search/query_language/ast.rb +141 -0
- data/lib/scoped_search/query_language/parser.rb +120 -0
- data/lib/scoped_search/query_language/tokenizer.rb +78 -0
- data/scoped_search.gemspec +32 -0
- data/spec/database.yml +25 -0
- data/spec/integration/api_spec.rb +82 -0
- data/spec/integration/ordinal_querying_spec.rb +158 -0
- data/spec/integration/relation_querying_spec.rb +262 -0
- data/spec/integration/string_querying_spec.rb +192 -0
- data/spec/lib/database.rb +49 -0
- data/spec/lib/matchers.rb +40 -0
- data/spec/lib/mocks.rb +19 -0
- data/spec/spec_helper.rb +19 -0
- data/spec/unit/ast_spec.rb +197 -0
- data/spec/unit/definition_spec.rb +24 -0
- data/spec/unit/parser_spec.rb +104 -0
- data/spec/unit/query_builder_spec.rb +22 -0
- data/spec/unit/tokenizer_spec.rb +97 -0
- data/tasks/github-gem.rake +323 -0
- metadata +117 -0
@@ -0,0 +1,49 @@
|
|
1
|
+
ActiveRecord::Migration.verbose = false unless ENV.has_key?('DEBUG')
|
2
|
+
|
3
|
+
module ScopedSearch::Spec::Database
|
4
|
+
|
5
|
+
def self.establish_connection
|
6
|
+
if ENV['DATABASE']
|
7
|
+
self.establish_named_connection(ENV['DATABASE'])
|
8
|
+
else
|
9
|
+
self.establish_default_connection
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.test_databases
|
14
|
+
@database_connections ||= YAML.load(File.read("#{File.dirname(__FILE__)}/../database.yml"))
|
15
|
+
@database_connections.keys
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.establish_named_connection(name)
|
19
|
+
@database_connections ||= YAML.load(File.read("#{File.dirname(__FILE__)}/../database.yml"))
|
20
|
+
raise "#{name} database not configured" if @database_connections[name.to_s].nil?
|
21
|
+
ActiveRecord::Base.establish_connection(@database_connections[name.to_s])
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.establish_default_connection
|
25
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:')
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.close_connection
|
29
|
+
ActiveRecord::Base.remove_connection
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.create_model(fields)
|
33
|
+
table_name = "model_#{rand}".gsub(/\./, '')
|
34
|
+
ActiveRecord::Migration.create_table(table_name) do |t|
|
35
|
+
fields.each do |name, field_type|
|
36
|
+
t.send(field_type.to_s.gsub(/^unindexed_/, '').to_sym, name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
klass = Class.new(ActiveRecord::Base)
|
41
|
+
klass.set_table_name(table_name)
|
42
|
+
yield(klass) if block_given?
|
43
|
+
return klass
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.drop_model(klass)
|
47
|
+
ActiveRecord::Migration.drop_table(klass.table_name)
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module ScopedSearch::Spec::Matchers
|
2
|
+
def be_infix_operator(operator = nil)
|
3
|
+
simple_matcher('node to be an infix operator') do |node|
|
4
|
+
node.kind_of?(ScopedSearch::QueryLanguage::AST::OperatorNode) &&
|
5
|
+
node.infix? && (operator.nil? || operator == node.operator)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def be_prefix_operator(operator = nil)
|
10
|
+
simple_matcher('node to be an prefix operator') do |node|
|
11
|
+
node.kind_of?(ScopedSearch::QueryLanguage::AST::OperatorNode) &&
|
12
|
+
node.prefix? && (operator.nil? || operator == node.operator)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def be_logical_operator(operator = nil)
|
17
|
+
simple_matcher('node to be an logical operator') do |node|
|
18
|
+
node.kind_of?(ScopedSearch::QueryLanguage::AST::LogicalOperatorNode) &&
|
19
|
+
(operator.nil? || operator == node.operator)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def be_leaf_node(value = nil)
|
24
|
+
simple_matcher('node to be an logical operator') do |node|
|
25
|
+
node.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode) && (value.nil? || value == node.value)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def tokenize_to(*tokens)
|
30
|
+
simple_matcher("to tokenize to #{tokens.inspect}") do |string|
|
31
|
+
tokens == ScopedSearch::QueryLanguage::Compiler.tokenize(string)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def parse_to(tree)
|
36
|
+
simple_matcher("to parse to #{tree.inspect}") do |string|
|
37
|
+
tree == ScopedSearch::QueryLanguage::Compiler.parse(string).to_a
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/spec/lib/mocks.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
module ScopedSearch::Spec::Mocks
|
2
|
+
|
3
|
+
def tree(array)
|
4
|
+
ScopedSearch::QueryLanguage::AST.from_array(array)
|
5
|
+
end
|
6
|
+
|
7
|
+
def mock_activerecord_class
|
8
|
+
ar_mock = mock('ActiveRecord::Base')
|
9
|
+
ar_mock.stub!(:named_scope).with(:search_for, anything)
|
10
|
+
ar_mock.stub!(:connection).and_return(mock_database_connection)
|
11
|
+
return ar_mock
|
12
|
+
end
|
13
|
+
|
14
|
+
def mock_database_connection
|
15
|
+
c_mock = mock('ActiveRecord::Base.connection')
|
16
|
+
return c_mock
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'spec'
|
5
|
+
require 'active_record'
|
6
|
+
|
7
|
+
require 'scoped_search'
|
8
|
+
|
9
|
+
module ScopedSearch::Spec; end
|
10
|
+
|
11
|
+
require "#{File.dirname(__FILE__)}/lib/matchers"
|
12
|
+
require "#{File.dirname(__FILE__)}/lib/database"
|
13
|
+
require "#{File.dirname(__FILE__)}/lib/mocks"
|
14
|
+
|
15
|
+
|
16
|
+
Spec::Runner.configure do |config|
|
17
|
+
config.include ScopedSearch::Spec::Matchers
|
18
|
+
config.include ScopedSearch::Spec::Mocks
|
19
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/../spec_helper"
|
2
|
+
|
3
|
+
describe ScopedSearch::QueryLanguage::AST do
|
4
|
+
|
5
|
+
describe '.from_array' do
|
6
|
+
|
7
|
+
context 'parsing a singular value' do
|
8
|
+
before(:each) do
|
9
|
+
@ast = ScopedSearch::QueryLanguage::AST.from_array('value')
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should create a leaf node for a normal value" do
|
13
|
+
@ast.should be_leaf_node('value')
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
context 'parsing a prefix operator' do
|
18
|
+
before(:each) do
|
19
|
+
@ast = ScopedSearch::QueryLanguage::AST.from_array([:not, 'a'])
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should create a operator node for an array starting with an operator" do
|
23
|
+
@ast.should be_prefix_operator(:not)
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should create a child node for every subsequent array item" do
|
27
|
+
@ast.should have(1).children
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should create set the RHS the the first child value" do
|
31
|
+
@ast.rhs.should be_leaf_node('a')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context 'pasring an infix operator' do
|
36
|
+
before(:each) do
|
37
|
+
@ast = ScopedSearch::QueryLanguage::AST.from_array([:eq, 'a', 'b'])
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should create a operator node for an array starting with an operator" do
|
41
|
+
@ast.should be_infix_operator(:eq)
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should create a child node for every subsequent array item" do
|
45
|
+
@ast.should have(2).children
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should create set the LHS the the first child value" do
|
49
|
+
@ast.lhs.should be_leaf_node('a')
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should create set the RHS the the second child value" do
|
53
|
+
@ast.rhs.should be_leaf_node('b')
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'parsing a nested logical construct' do
|
58
|
+
before(:each) do
|
59
|
+
@ast = ScopedSearch::QueryLanguage::AST.from_array([:and, 'a', [:or, 'b', [:not, 'c']]])
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should create a logical operator node for an array starting with :and" do
|
63
|
+
@ast.should be_logical_operator(:and)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should create a child node for every subsequent array item" do
|
67
|
+
@ast.should have(2).children
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should create a nested OR structure for a nested array" do
|
71
|
+
@ast.lhs.should be_leaf_node('a')
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should create a nested OR structure for a nested array" do
|
75
|
+
@ast.rhs.should be_logical_operator(:or)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should create a leaf node in the nested OR structure" do
|
79
|
+
@ast.rhs.lhs.should be_leaf_node('b')
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should create a NOT operator in the nested OR structure" do
|
83
|
+
@ast.rhs.rhs.should be_prefix_operator(:not)
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should create a leaf node in the nested OR structure" do
|
87
|
+
@ast.rhs.rhs.rhs.should be_leaf_node('c')
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Recursive tree simplification algorithm
|
93
|
+
describe '#simplify' do
|
94
|
+
|
95
|
+
it "should not simplify a leaf node" do
|
96
|
+
tree('value').simplify.should eql(tree('value'))
|
97
|
+
end
|
98
|
+
|
99
|
+
it "should not simplify a prefix operator node node" do
|
100
|
+
tree([:lt, '1']).simplify.should eql(tree([:lt, '1']))
|
101
|
+
end
|
102
|
+
|
103
|
+
it "should not simplify an infix operator node node" do
|
104
|
+
tree([:lt, 'field', '1']).simplify.should eql(tree([:lt, 'field', '1']))
|
105
|
+
end
|
106
|
+
|
107
|
+
it "should simplify a single value in a logical operator" do
|
108
|
+
tree([:and, 'a']).simplify.should eql(tree('a'))
|
109
|
+
end
|
110
|
+
|
111
|
+
it "should simplify a single operator in a logical operator" do
|
112
|
+
tree([:or, [:gt, '2']]).simplify.should eql(tree([:gt, '2']))
|
113
|
+
end
|
114
|
+
|
115
|
+
it "should not simplify a logical operator with multiple values" do
|
116
|
+
tree([:and, 'a', 'b']).simplify.should eql(tree([:and, 'a', 'b']))
|
117
|
+
end
|
118
|
+
|
119
|
+
it "should simplify nested logial operators" do
|
120
|
+
tree([:and, [:and, 'a', 'b'], [:and, 'c']]).simplify.should eql(tree([:and, 'a', 'b', 'c']))
|
121
|
+
end
|
122
|
+
|
123
|
+
it "should simplify double nested logial operators" do
|
124
|
+
tree([:and, [:and, 'a', [:and, 'b', 'c']]]).simplify.should eql(tree([:and, 'a', 'b', 'c']))
|
125
|
+
end
|
126
|
+
|
127
|
+
it "should not simplify nested operators if the operators are different" do
|
128
|
+
tree([:or, 'a', [:and, 'b', 'c']]).simplify.should eql(tree([:or, 'a', [:and, 'b', 'c']]))
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
describe ScopedSearch::QueryLanguage::AST::OperatorNode do
|
134
|
+
|
135
|
+
context 'with 1 child' do
|
136
|
+
before(:each) do
|
137
|
+
@node = tree([:not, '1'])
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'should be a prefix operator' do
|
141
|
+
@node.should be_prefix
|
142
|
+
end
|
143
|
+
|
144
|
+
it "should not be an infix operator" do
|
145
|
+
@node.should_not be_infix
|
146
|
+
end
|
147
|
+
|
148
|
+
it 'should return the first child as RHS' do
|
149
|
+
@node.rhs.should eql(@node[0])
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should raise an error of the LHS is requested" do
|
153
|
+
lambda { @node.lhs }.should raise_error
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
context 'with 2 children' do
|
158
|
+
before(:each) do
|
159
|
+
@node = tree([:eq, '1', '2'])
|
160
|
+
end
|
161
|
+
|
162
|
+
it "should be an infix operator" do
|
163
|
+
@node.should be_infix
|
164
|
+
end
|
165
|
+
|
166
|
+
it "should not be a prefix operator" do
|
167
|
+
@node.should_not be_prefix
|
168
|
+
end
|
169
|
+
|
170
|
+
it "should return the first child as LHS" do
|
171
|
+
@node.lhs.should eql(@node[0])
|
172
|
+
end
|
173
|
+
|
174
|
+
it "should return the second child as RHS" do
|
175
|
+
@node.rhs.should eql(@node[1])
|
176
|
+
end
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
context 'many children' do
|
181
|
+
before(:each) do
|
182
|
+
@node = tree([:and, '1', '2', '3', '4'])
|
183
|
+
end
|
184
|
+
|
185
|
+
it "should be an infix operator" do
|
186
|
+
@node.should be_infix
|
187
|
+
end
|
188
|
+
|
189
|
+
it "should raise an error of the LHS is requested" do
|
190
|
+
lambda { @node.lhs }.should raise_error
|
191
|
+
end
|
192
|
+
|
193
|
+
it "should raise an error of the RHS is requested" do
|
194
|
+
lambda { @node.rhs }.should raise_error
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/../spec_helper"
|
2
|
+
|
3
|
+
describe ScopedSearch::Definition do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
@klass = mock_activerecord_class
|
7
|
+
@definition = ScopedSearch::Definition.new(@klass)
|
8
|
+
@definition.stub!(:setup_adapter)
|
9
|
+
end
|
10
|
+
|
11
|
+
describe '#initialize' do
|
12
|
+
|
13
|
+
it "should create the named scope when" do
|
14
|
+
@klass.should_receive(:named_scope).with(:search_for, instance_of(Proc))
|
15
|
+
ScopedSearch::Definition.new(@klass)
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should not create the named scope if it already exists" do
|
19
|
+
@klass.stub!(:search_for)
|
20
|
+
@klass.should_not_receive(:named_scope)
|
21
|
+
ScopedSearch::Definition.new(@klass)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/../spec_helper"
|
2
|
+
|
3
|
+
describe ScopedSearch::QueryLanguage::Parser do
|
4
|
+
|
5
|
+
it "should create a 1-node tree for a single keyword" do
|
6
|
+
'single'.should parse_to('single')
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should create a two-item AND construct for two keywords" do
|
10
|
+
'double_1 double_2'.should parse_to([:and, 'double_1', 'double_2'])
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should create a three-item AND construct for three keywords" do
|
14
|
+
'triplet_1 triplet_2 triplet_3'.should parse_to([:and, 'triplet_1', 'triplet_2', 'triplet_3'])
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should create a four-item AND construct by simplifying AND constructs" do
|
18
|
+
'1 (2 (3 4))'.should parse_to([:and, '1', '2', '3', '4'])
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should create an OR construct" do
|
22
|
+
'some OR simple OR keywords'.should parse_to([:or, 'some', 'simple', 'keywords'])
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should nest an OR as second argument of the AND construct" do
|
26
|
+
'some simple OR keywords'.should parse_to([:and, 'some', [:or, 'simple', 'keywords']])
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should nest an OR as first argument of an AND construct" do
|
30
|
+
'some OR simple keywords'.should parse_to([:and, [:or, 'some', 'simple'], 'keywords'])
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should handle parenthesis as AND block, placed in an OR block" do
|
34
|
+
'some OR (simple keywords)'.should parse_to([:or, 'some', [:and, 'simple', 'keywords']])
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should handle parenthesis as OR block in an AND block" do
|
38
|
+
'(some OR simple) keywords'.should parse_to([:and, [:or, 'some', 'simple'], 'keywords'])
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should create a block for the NOT keyword" do
|
42
|
+
'not easy'.should parse_to([:not, 'easy'])
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should create a nsted NOT block" do
|
46
|
+
'not !easy'.should parse_to([:not, [:not, 'easy']])
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should create a block for the NOT keyword in an AND block" do
|
50
|
+
'hard !easy'.should parse_to([:and, 'hard', [:not, 'easy']])
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should create a block for the NOT keyword in an OR block" do
|
54
|
+
'hard || !easy'.should parse_to([:or, 'hard', [:not, 'easy']])
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should create a block for the NOT keyword in an OR block" do
|
58
|
+
'!easy OR !hard'.should parse_to([:or, [:not, 'easy'], [:not, 'hard']])
|
59
|
+
end
|
60
|
+
|
61
|
+
it "should nest the NOT blocks correctly according to parantheses" do
|
62
|
+
'!(easy OR !hard)'.should parse_to([:not, [:or, 'easy', [:not, 'hard']]])
|
63
|
+
end
|
64
|
+
|
65
|
+
it "should create OR blocks in an AND block" do
|
66
|
+
'(a|b)(b|c)'.should parse_to([:and, [:or, 'a', 'b'], [:or, 'b', 'c']])
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should create OR blocks in an explicit AND block" do
|
70
|
+
'(a|b)&(b|c)'.should parse_to([:and, [:or, 'a', 'b'], [:or, 'b', 'c']])
|
71
|
+
end
|
72
|
+
|
73
|
+
it "should ignore a comma under normal circumstances" do
|
74
|
+
'a,b'.should parse_to([:and, 'a', 'b'])
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should correctly parse an infix comparison" do
|
78
|
+
'a>b'.should parse_to([:gt, 'a', 'b'])
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should correctly parse a prefix comparison" do
|
82
|
+
'<b'.should parse_to([:lt, 'b'])
|
83
|
+
end
|
84
|
+
|
85
|
+
it "should create a comparison in an AND block because of the comma delimiter" do
|
86
|
+
'a, < b'.should parse_to([:and, 'a', [:lt, 'b']])
|
87
|
+
end
|
88
|
+
|
89
|
+
it "should create a infix and prefix comparison in an AND block because of parentheses" do
|
90
|
+
'(a = b) >c'.should parse_to([:and, [:eq, 'a', 'b'], [:gt, 'c']])
|
91
|
+
end
|
92
|
+
|
93
|
+
it "should create a infix and prefix comparison in an AND block because of a comma" do
|
94
|
+
'a = b, >c'.should parse_to([:and, [:eq, 'a', 'b'], [:gt, 'c']])
|
95
|
+
end
|
96
|
+
|
97
|
+
it "should create a infix and prefix comparison in an AND block because of first come first serve" do
|
98
|
+
'a = b > c'.should parse_to([:and, [:eq, 'a', 'b'], [:gt, 'c']])
|
99
|
+
end
|
100
|
+
|
101
|
+
it "should parse a null? keyword" do
|
102
|
+
'set? a b null? c'.should parse_to([:and, [:notnull, 'a'], 'b', [:null, 'c']])
|
103
|
+
end
|
104
|
+
end
|