wvanbergen-scoped_search 1.2.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. data/README.rdoc +48 -32
  2. data/Rakefile +1 -3
  3. data/lib/scoped_search.rb +45 -95
  4. data/lib/scoped_search/adapters.rb +41 -0
  5. data/lib/scoped_search/definition.rb +122 -0
  6. data/lib/scoped_search/query_builder.rb +213 -0
  7. data/lib/scoped_search/query_language.rb +30 -0
  8. data/lib/scoped_search/query_language/ast.rb +141 -0
  9. data/lib/scoped_search/query_language/parser.rb +115 -0
  10. data/lib/scoped_search/query_language/tokenizer.rb +62 -0
  11. data/{test → spec}/database.yml +0 -0
  12. data/spec/integration/api_spec.rb +82 -0
  13. data/spec/integration/ordinal_querying_spec.rb +153 -0
  14. data/spec/integration/relation_querying_spec.rb +258 -0
  15. data/spec/integration/string_querying_spec.rb +187 -0
  16. data/spec/lib/database.rb +44 -0
  17. data/spec/lib/matchers.rb +40 -0
  18. data/spec/lib/mocks.rb +19 -0
  19. data/spec/spec_helper.rb +21 -0
  20. data/spec/unit/ast_spec.rb +197 -0
  21. data/spec/unit/definition_spec.rb +24 -0
  22. data/spec/unit/parser_spec.rb +105 -0
  23. data/spec/unit/query_builder_spec.rb +5 -0
  24. data/spec/unit/tokenizer_spec.rb +97 -0
  25. data/tasks/database_tests.rake +5 -5
  26. data/tasks/github-gem.rake +8 -3
  27. metadata +39 -23
  28. data/lib/scoped_search/query_conditions_builder.rb +0 -209
  29. data/lib/scoped_search/query_language_parser.rb +0 -117
  30. data/lib/scoped_search/reg_tokens.rb +0 -51
  31. data/tasks/documentation.rake +0 -33
  32. data/test/integration/api_test.rb +0 -53
  33. data/test/lib/test_models.rb +0 -148
  34. data/test/lib/test_schema.rb +0 -68
  35. data/test/test_helper.rb +0 -44
  36. data/test/unit/query_conditions_builder_test.rb +0 -410
  37. data/test/unit/query_language_test.rb +0 -155
  38. data/test/unit/search_for_test.rb +0 -124
@@ -0,0 +1,30 @@
1
+ module ScopedSearch::QueryLanguage
2
+
3
+ require 'scoped_search/query_language/ast'
4
+ require 'scoped_search/query_language/tokenizer'
5
+ require 'scoped_search/query_language/parser'
6
+
7
+ class Compiler
8
+
9
+ include Tokenizer
10
+ include Parser
11
+ include Enumerable
12
+
13
+ def initialize(str)
14
+ @str = str
15
+ end
16
+
17
+ def self.parse(str)
18
+ compiler = self.new(str)
19
+ compiler.parse
20
+ end
21
+
22
+ def self.tokenize(str)
23
+ compiler = self.new(str)
24
+ compiler.tokenize
25
+ end
26
+
27
+ end
28
+ end
29
+
30
+
@@ -0,0 +1,141 @@
1
+ module ScopedSearch::QueryLanguage::AST
2
+
3
+ # Constructs an AST from an array notation.
4
+ def self.from_array(arg)
5
+ if arg.kind_of?(Array)
6
+ operator = arg.shift
7
+ case operator
8
+ when :and, :or
9
+ LogicalOperatorNode.new(operator, arg.map { |c| from_array(c) })
10
+ when Symbol
11
+ OperatorNode.new(operator, arg.map { |c| from_array(c) })
12
+ else
13
+ raise ScopedSearch::Exception, "Not a valid array representation of an AST!"
14
+ end
15
+ else
16
+ return LeafNode.new(arg)
17
+ end
18
+ end
19
+
20
+ # Base AST node class. Instances of this class are used to represent an abstract syntax tree.
21
+ # This syntax tree is created by the ScopedSearch::QueryLanguage parser and visited by the
22
+ # ScopedSearch::QueryBuilder to create SQL query conditions.
23
+ class Node
24
+
25
+ def inspect # :nodoc
26
+ "<AST::#{self.class.to_s.split('::').last} #{self.to_a.inspect}>"
27
+ end
28
+
29
+ # Tree simplification. By default, do nothing and return the node as is.
30
+ def simplify
31
+ return self
32
+ end
33
+
34
+ def compatible_with(node) # :nodoc
35
+ false
36
+ end
37
+ end
38
+
39
+ # AST lead node. This node represents leafs in the AST and can represent
40
+ # either a search phrase or a search field name.
41
+ class LeafNode < Node
42
+ attr_reader :value
43
+
44
+ def initialize(value) # :nodoc
45
+ @value = value
46
+ end
47
+
48
+ # Return an array representation for the node
49
+ def to_a
50
+ value
51
+ end
52
+
53
+ def eql?(node) # :nodoc
54
+ node.kind_of?(LeafNode) && node.value == value
55
+ end
56
+ end
57
+
58
+ # AST class for representing operators in the query. An operator node has an operator
59
+ # and operands that are represented as AST child nodes. Usually, operator nodes have
60
+ # one or two children.
61
+ # For logical operators, a distinct subclass exists to implement some tree
62
+ # simplification rules.
63
+ class OperatorNode < Node
64
+ attr_reader :operator
65
+ attr_reader :children
66
+
67
+ def initialize(operator, children) # :nodoc
68
+ @operator = operator
69
+ @children = children
70
+ end
71
+
72
+ # Tree simplicication: returns itself after simpifying its children
73
+ def simplify
74
+ @children = children.map { |c| c.simplify }
75
+ return self
76
+ end
77
+
78
+ # Return an array representation for the node
79
+ def to_a
80
+ [@operator] + @children.map { |c| c.to_a }
81
+ end
82
+
83
+ def eql?(node) # :nodoc
84
+ node.kind_of?(OperatorNode) && node.operator == operator && node.children.eql?(children)
85
+ end
86
+
87
+ # Return the left-hand side (LHS) operand for this operator.
88
+ def lhs
89
+ raise ScopedSearch::Exception, "Operator does not have a LHS" if prefix?
90
+ raise ScopedSearch::Exception, "Operators with more than 2 children do not have LHS/RHS" if children.length > 2
91
+ children[0]
92
+ end
93
+
94
+ # Return the right-hand side (RHS) operand for this operator.
95
+ def rhs
96
+ raise ScopedSearch::Exception, "Operators with more than 2 children do not have LHS/RHS" if children.length > 2
97
+ children.length == 1 ? children[0] : children[1]
98
+ end
99
+
100
+ # Returns true if this is an infix operator
101
+ def infix?
102
+ children.length > 1
103
+ end
104
+
105
+ # Returns true if this is a prefix operator
106
+ def prefix?
107
+ children.length == 1
108
+ end
109
+
110
+ # Returns a child node by index, starting with 0.
111
+ def [](child_nr)
112
+ children[child_nr]
113
+ end
114
+
115
+ end
116
+
117
+ # AST class for representing AND or OR constructs.
118
+ # Logical constructs can be simplified resulting in a less complex AST.
119
+ class LogicalOperatorNode < OperatorNode
120
+
121
+ # Checks whether another node is comparable so that it can be used for tree simplification.
122
+ # A node can only be simplified if the logical operator is equal.
123
+ def compatible_with(node)
124
+ node.kind_of?(LogicalOperatorNode) && node.operator == self.operator
125
+ end
126
+
127
+ # Simplifies nested AND and OR constructs to single constructs with multiple arguments:
128
+ # e.g. (a AND (b AND c)) -> (a AND b AND c)
129
+ def simplify
130
+ if children.length == 1
131
+ # AND or OR constructs do nothing if they only have one operand
132
+ # So remove the logal operator from the AST by simply using the opeand
133
+ return children.first.simplify
134
+ else
135
+ # nested AND or OR constructs can be combined into one construct
136
+ @children = children.map { |c| c.simplify }.map { |c| self.compatible_with(c) ? c.children : c }.flatten
137
+ return self
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,115 @@
1
+ module ScopedSearch::QueryLanguage::Parser
2
+
3
+ DEFAULT_SEQUENCE_OPERATOR = :and
4
+
5
+ LOGICAL_INFIX_OPERATORS = [:and, :or]
6
+ LOGICAL_PREFIX_OPERATORS = [:not]
7
+ NULL_PREFIX_OPERATORS = [:null, :notnull]
8
+ COMPARISON_OPERATORS = [:eq, :ne, :gt, :gte, :lt, :lte, :like, :unlike]
9
+ ALL_INFIX_OPERATORS = LOGICAL_INFIX_OPERATORS + COMPARISON_OPERATORS
10
+ ALL_PREFIX_OPERATORS = LOGICAL_PREFIX_OPERATORS + COMPARISON_OPERATORS + NULL_PREFIX_OPERATORS
11
+
12
+ # Start the parsing process by parsing an expression sequence
13
+ def parse
14
+ @tokens = tokenize
15
+ parse_expression_sequence(true).simplify
16
+ end
17
+
18
+ # Parses a sequence of expressions
19
+ def parse_expression_sequence(initial = false)
20
+ expressions = []
21
+ next_token if !initial && peek_token == :lparen # skip staring :lparen
22
+ expressions << parse_logical_expression until peek_token.nil? || peek_token == :rparen
23
+ next_token if !initial && peek_token == :rparen # skip final :rparen
24
+ return ScopedSearch::QueryLanguage::AST::LogicalOperatorNode.new(DEFAULT_SEQUENCE_OPERATOR, expressions)
25
+ end
26
+
27
+
28
+ # Parses a logical expression.
29
+ def parse_logical_expression
30
+ lhs = case peek_token
31
+ when nil; nil
32
+ when :lparen; parse_expression_sequence
33
+ when :not; parse_logical_not_expression
34
+ when :null, :notnull; parse_null_expression
35
+ else; parse_comparison
36
+ end
37
+
38
+ if LOGICAL_INFIX_OPERATORS.include?(peek_token)
39
+ operator = next_token
40
+ rhs = parse_logical_expression
41
+ ScopedSearch::QueryLanguage::AST::LogicalOperatorNode.new(operator, [lhs, rhs])
42
+ else
43
+ lhs
44
+ end
45
+ end
46
+
47
+ # Parses a NOT expression
48
+ def parse_logical_not_expression
49
+ next_token # = skip NOT operator
50
+ negated_expression = case peek_token
51
+ when :not; parse_logical_not_expression
52
+ when :lparen; parse_expression_sequence
53
+ else parse_comparison
54
+ end
55
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(:not, [negated_expression])
56
+ end
57
+
58
+ # Parses a set? or null? expression
59
+ def parse_null_expression
60
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(next_token, [parse_value])
61
+ end
62
+
63
+ # Parses a comparison
64
+ def parse_comparison
65
+ next_token if peek_token == :comma # skip comma
66
+ return (String === peek_token) ? parse_infix_comparison : parse_prefix_comparison
67
+ end
68
+
69
+ # Parses a prefix comparison, i.e. without an explicit field: <operator> <value>
70
+ def parse_prefix_comparison
71
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(next_token, [parse_value])
72
+ end
73
+
74
+ # Parses an infix expression, i.e. <field> <operator> <value>
75
+ def parse_infix_comparison
76
+ lhs = parse_value
77
+ return case peek_token
78
+ when nil
79
+ lhs
80
+ when :comma
81
+ next_token # skip comma
82
+ lhs
83
+ else
84
+ if COMPARISON_OPERATORS.include?(peek_token)
85
+ comparison_operator = next_token
86
+ rhs = parse_value
87
+ ScopedSearch::QueryLanguage::AST::OperatorNode.new(comparison_operator, [lhs, rhs])
88
+ else
89
+ lhs
90
+ end
91
+ end
92
+ end
93
+
94
+ # Parses a single value.
95
+ # This can either be a constant value or a field name.
96
+ def parse_value
97
+ raise ScopedSearch::QueryNotSupported, "Value expected but found #{peek_token.inspect}" unless String === peek_token
98
+ ScopedSearch::QueryLanguage::AST::LeafNode.new(next_token)
99
+ end
100
+
101
+ protected
102
+
103
+ def current_token
104
+ @current_token
105
+ end
106
+
107
+ def peek_token(amount = 1)
108
+ @tokens[amount - 1]
109
+ end
110
+
111
+ def next_token
112
+ @current_token = @tokens.shift
113
+ end
114
+
115
+ end
@@ -0,0 +1,62 @@
1
+ module ScopedSearch::QueryLanguage::Tokenizer
2
+
3
+ KEYWORDS = { 'and' => :and, 'or' => :or, 'not' => :not, 'set?' => :notnull, 'null?' => :null }
4
+ OPERATORS = { '&' => :and, '|' => :or, '&&' => :and, '||' => :or, '-'=> :not, '!' => :not, '~' => :like, '!~' => :unlike,
5
+ '=' => :eq, '==' => :eq, '!=' => :ne, '<>' => :ne, '>' => :gt, '<' => :lt, '>=' => :gte, '<=' => :lte }
6
+
7
+
8
+ def tokenize
9
+ @current_char_pos = -1
10
+ to_a
11
+ end
12
+
13
+ def current_char
14
+ @current_char
15
+ end
16
+
17
+ def peek_char(amount = 1)
18
+ @str[@current_char_pos + amount, 1]
19
+ end
20
+
21
+ def next_char
22
+ @current_char_pos += 1
23
+ @current_char = @str[@current_char_pos, 1]
24
+ end
25
+
26
+ def each_token(&block)
27
+ while next_char
28
+ case current_char
29
+ when /^\s?$/; # ignore
30
+ when '('; yield(:lparen)
31
+ when ')'; yield(:rparen)
32
+ when ','; yield(:comma)
33
+ when /\&|\||=|<|>|!|~|-/; tokenize_operator(&block)
34
+ when '"'; tokenize_quoted_keyword(&block)
35
+ else; tokenize_keyword(&block)
36
+ end
37
+ end
38
+ end
39
+
40
+ def tokenize_operator(&block)
41
+ operator = current_char
42
+ operator << next_char if OPERATORS.has_key?(operator + peek_char)
43
+ yield(OPERATORS[operator])
44
+ end
45
+
46
+ def tokenize_keyword(&block)
47
+ keyword = current_char
48
+ keyword << next_char while /[^=<>\s\&\|\)\(,]/ =~ peek_char
49
+ KEYWORDS.has_key?(keyword.downcase) ? yield(KEYWORDS[keyword.downcase]) : yield(keyword)
50
+ end
51
+
52
+ def tokenize_quoted_keyword(&block)
53
+ keyword = ""
54
+ until next_char.nil? || current_char == '"'
55
+ keyword << (current_char == "\\" ? next_char : current_char)
56
+ end
57
+ yield(keyword)
58
+ end
59
+
60
+ alias :each :each_token
61
+
62
+ end
File without changes
@@ -0,0 +1,82 @@
1
+ require "#{File.dirname(__FILE__)}/../spec_helper"
2
+
3
+ describe ScopedSearch, "API" do
4
+
5
+ # This spec requires the API to be stable, so that projects using
6
+ # scoped_search do not have to update their code if a new (minor)
7
+ # version is released.
8
+ #
9
+ # API compatibility is only guaranteed for minor version changes;
10
+ # New major versions may change the API and require code changes
11
+ # in projects using this plugin.
12
+ #
13
+ # Because of the API stability guarantee, these spec's may only
14
+ # be changed for new major releases.
15
+
16
+ before(:all) do
17
+ ScopedSearch::Spec::Database.establish_connection
18
+ end
19
+
20
+ after(:all) do
21
+ ScopedSearch::Spec::Database.close_connection
22
+ end
23
+
24
+ context 'for unprepared ActiveRecord model' do
25
+
26
+ it "should respond to :scoped_search to setup scoped_search for the model" do
27
+ Class.new(ActiveRecord::Base).should respond_to(:scoped_search)
28
+ end
29
+ end
30
+
31
+ context 'for a prepared ActiveRecord model' do
32
+
33
+ before(:all) do
34
+ @class = ScopedSearch::Spec::Database.create_model(:field => :string) do |klass|
35
+ klass.scoped_search :on => :field
36
+ end
37
+ end
38
+
39
+ after(:all) do
40
+ ScopedSearch::Spec::Database.drop_model(@class)
41
+ end
42
+
43
+ it "should respond to :search_for to perform searches" do
44
+ @class.should respond_to(:search_for)
45
+ end
46
+
47
+ it "should return an ActiveRecord::NamedScope::Scope when :search_for is called" do
48
+ @class.search_for('query').class.should eql(ActiveRecord::NamedScope::Scope)
49
+ end
50
+ end
51
+
52
+ context 'having backwards compatibility' do
53
+
54
+ before(:each) do
55
+ class Foo < ActiveRecord::Base
56
+ belongs_to :bar
57
+ end
58
+ end
59
+
60
+ after(:each) do
61
+ Object.send :remove_const, :Foo
62
+ end
63
+
64
+ it "should respond to :searchable_on" do
65
+ Foo.should respond_to(:searchable_on)
66
+ end
67
+
68
+ it "should create a Field instance for every argument" do
69
+ ScopedSearch::Definition::Field.should_receive(:new).exactly(3).times
70
+ Foo.searchable_on(:field_1, :field_2, :field_3)
71
+ end
72
+
73
+ it "should create a Field with a valid relation when using the underscore notation" do
74
+ ScopedSearch::Definition::Field.should_receive(:new).with(
75
+ instance_of(ScopedSearch::Definition), hash_including(:in => :bar, :on => :related_field))
76
+
77
+ Foo.searchable_on(:bar_related_field)
78
+ end
79
+
80
+ end
81
+
82
+ end
@@ -0,0 +1,153 @@
1
+ require "#{File.dirname(__FILE__)}/../spec_helper"
2
+
3
+ describe ScopedSearch do
4
+
5
+ before(:all) do
6
+ ScopedSearch::Spec::Database.establish_connection
7
+
8
+ @class = ScopedSearch::Spec::Database.create_model(:int => :integer, :timestamp => :datetime, :date => :date, :unindexed => :integer) do |klass|
9
+ klass.scoped_search :on => [:int, :timestamp]
10
+ klass.scoped_search :on => :date, :only_explicit => true
11
+ end
12
+ end
13
+
14
+ after(:all) do
15
+ ScopedSearch::Spec::Database.drop_model(@class)
16
+ ScopedSearch::Spec::Database.close_connection
17
+ end
18
+
19
+ context 'quering numerical fields' do
20
+
21
+ before(:all) do
22
+ @record = @class.create!(:int => 9)
23
+ end
24
+
25
+ after(:all) do
26
+ @record.destroy
27
+ end
28
+
29
+ it "should find the record with an exact integer match" do
30
+ @class.search_for('9').should have(1).item
31
+ end
32
+
33
+ it "should find the record with an exact integer match with an explicit operator" do
34
+ @class.search_for('= 9').should have(1).item
35
+ end
36
+
37
+ it "should find the record with an exact integer match with an explicit field name" do
38
+ @class.search_for('int = 9').should have(1).item
39
+ end
40
+
41
+ it "should find the record with an exact integer match with an explicit field name" do
42
+ @class.search_for('int > 8').should have(1).item
43
+ end
44
+
45
+ it "should find the record with a grater than operator and explicit field" do
46
+ @class.search_for('int > 9').should have(0).item
47
+ end
48
+
49
+ it "should find the record with an >= operator with an implicit field name" do
50
+ @class.search_for('>= 9').should have(1).item
51
+ end
52
+
53
+ it "should not return the record if only one predicate is true and AND is used (by default)" do
54
+ @class.search_for('int <= 8, int > 8').should have(0).item
55
+ end
56
+
57
+ it "should return the record in only one predicate is true and OR is used as operator" do
58
+ @class.search_for('int <= 8 || int > 8').should have(1).item
59
+ end
60
+ end
61
+
62
+ context 'querying unindexed fields' do
63
+
64
+ before(:all) do
65
+ @record = @class.create!(:int => 9, :unindexed => 10)
66
+ end
67
+
68
+ after(:all) do
69
+ @record.destroy
70
+ end
71
+
72
+ it "should raise an error when explicitly searching in the non-indexed column" do
73
+ lambda { @class.search_for('unindexed = 10') }.should raise_error(ScopedSearch::Exception)
74
+ end
75
+
76
+ it "should not return records for which the query matches unindex records" do
77
+ @class.search_for('= 10').should have(0).item
78
+ end
79
+ end
80
+
81
+ context 'querying date and time fields' do
82
+
83
+ before(:all) do
84
+ @record = @class.create!(:timestamp => Time.parse('2009-01-02 14:51:44'), :date => Date.parse('2009-01-02'))
85
+ @nil_record = @class.create!(:timestamp => nil, :date => nil)
86
+ end
87
+
88
+ after(:all) do
89
+ @record.destroy
90
+ @nil_record.destroy
91
+ end
92
+
93
+ it "should accept YYYY-MM-DD as date format" do
94
+ @class.search_for('date = 2009-01-02').should have(1).item
95
+ end
96
+
97
+ it "should accept YY-MM-DD as date format" do
98
+ @class.search_for('date = 09-01-02').should have(1).item
99
+ end
100
+
101
+ it "should accept MM/DD/YY as date format" do
102
+ @class.search_for('date = 01/02/09').should have(1).item
103
+ end
104
+
105
+ it "should accept YYYY/MM/DD as date format" do
106
+ @class.search_for('date = 2009/01/02').should have(1).item
107
+ end
108
+
109
+ it "should accept MM/DD/YYYY as date format" do
110
+ @class.search_for('date = 01/02/2009').should have(1).item
111
+ end
112
+
113
+ it "should ignore an invalid date and thus return all records" do
114
+ @class.search_for('>= 2009-14-57').should have(2).items
115
+ end
116
+
117
+ it "should find the records with a timestamp set some point on the provided date" do
118
+ @class.search_for('>= 2009-01-02').should have(1).item
119
+ end
120
+
121
+ it "should support full timestamps" do
122
+ @class.search_for('> "2009-01-02 02:02:02"').should have(1).item
123
+ end
124
+
125
+ it "should find no record with a timestamp in the past" do
126
+ @class.search_for('< 2009-01-02').should have(0).item
127
+ end
128
+
129
+ it "should find all timestamps on a date if no time is given using the = operator" do
130
+ @class.search_for('= 2009-01-02').should have(1).item
131
+ end
132
+
133
+ it "should find all timestamps on a date if no time is when no operator is given" do
134
+ @class.search_for('2009-01-02').should have(1).item
135
+ end
136
+
137
+ it "should find all timestamps not on a date if no time is given using the != operator" do
138
+ @class.search_for('!= 2009-01-02').should have(0).item
139
+ end
140
+
141
+ it "should find the records when the date part of a timestamp matches a date" do
142
+ @class.search_for('>= 2009-01-02').should have(1).item
143
+ end
144
+
145
+ it "should find the record with the timestamp today or in the past" do
146
+ @class.search_for('<= 2009-01-02').should have(1).item
147
+ end
148
+
149
+ it "should find no record with a timestamp later than today" do
150
+ @class.search_for('> 2009-01-02').should have(0).item
151
+ end
152
+ end
153
+ end