scoped_search 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
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
+ # The Compiler class can compile a query string into an Abstract Syntax Tree,
8
+ # which in turn is used to build the SQL query.
9
+ #
10
+ # This class inclused the Tokenizer module to transform the query stream into
11
+ # a stream of tokens, and includes the Parser module that will transform the
12
+ # stream of tokens into an Abstract Syntax Tree (AST).
13
+ class Compiler
14
+
15
+ include Tokenizer
16
+ include Parser
17
+ include Enumerable
18
+
19
+ def initialize(str) # :nodoc:
20
+ @str = str
21
+ end
22
+
23
+ # Parser a query string to return an abstract syntax tree.
24
+ def self.parse(str)
25
+ compiler = self.new(str)
26
+ compiler.parse
27
+ end
28
+
29
+ # Tokenizes a query string to return a stream of tokens.
30
+ def self.tokenize(str)
31
+ compiler = self.new(str)
32
+ compiler.tokenize
33
+ end
34
+
35
+ end
36
+ end
37
+
38
+
@@ -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,120 @@
1
+ # The Parser module adss methods to the query language compiler that transform a string
2
+ # into an abstract syntax tree, which can be used for query generation.
3
+ #
4
+ # This module depends on the tokeinzer module to transform the string into a stream
5
+ # of tokens, which is more appropriate for parsing. The parser itself is a LL(1)
6
+ # recursive descent parser.
7
+ module ScopedSearch::QueryLanguage::Parser
8
+
9
+ DEFAULT_SEQUENCE_OPERATOR = :and
10
+
11
+ LOGICAL_INFIX_OPERATORS = [:and, :or]
12
+ LOGICAL_PREFIX_OPERATORS = [:not]
13
+ NULL_PREFIX_OPERATORS = [:null, :notnull]
14
+ COMPARISON_OPERATORS = [:eq, :ne, :gt, :gte, :lt, :lte, :like, :unlike]
15
+ ALL_INFIX_OPERATORS = LOGICAL_INFIX_OPERATORS + COMPARISON_OPERATORS
16
+ ALL_PREFIX_OPERATORS = LOGICAL_PREFIX_OPERATORS + COMPARISON_OPERATORS + NULL_PREFIX_OPERATORS
17
+
18
+ # Start the parsing process by parsing an expression sequence
19
+ def parse
20
+ @tokens = tokenize
21
+ parse_expression_sequence(true).simplify
22
+ end
23
+
24
+ # Parses a sequence of expressions
25
+ def parse_expression_sequence(initial = false)
26
+ expressions = []
27
+ next_token if !initial && peek_token == :lparen # skip staring :lparen
28
+ expressions << parse_logical_expression until peek_token.nil? || peek_token == :rparen
29
+ next_token if !initial && peek_token == :rparen # skip final :rparen
30
+ return ScopedSearch::QueryLanguage::AST::LogicalOperatorNode.new(DEFAULT_SEQUENCE_OPERATOR, expressions)
31
+ end
32
+
33
+ # Parses a logical expression.
34
+ def parse_logical_expression
35
+ lhs = case peek_token
36
+ when nil; nil
37
+ when :lparen; parse_expression_sequence
38
+ when :not; parse_logical_not_expression
39
+ when :null, :notnull; parse_null_expression
40
+ else; parse_comparison
41
+ end
42
+
43
+ if LOGICAL_INFIX_OPERATORS.include?(peek_token)
44
+ operator = next_token
45
+ rhs = parse_logical_expression
46
+ ScopedSearch::QueryLanguage::AST::LogicalOperatorNode.new(operator, [lhs, rhs])
47
+ else
48
+ lhs
49
+ end
50
+ end
51
+
52
+ # Parses a NOT expression
53
+ def parse_logical_not_expression
54
+ next_token # = skip NOT operator
55
+ negated_expression = case peek_token
56
+ when :not; parse_logical_not_expression
57
+ when :lparen; parse_expression_sequence
58
+ else parse_comparison
59
+ end
60
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(:not, [negated_expression])
61
+ end
62
+
63
+ # Parses a set? or null? expression
64
+ def parse_null_expression
65
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(next_token, [parse_value])
66
+ end
67
+
68
+ # Parses a comparison
69
+ def parse_comparison
70
+ next_token if peek_token == :comma # skip comma
71
+ return (String === peek_token) ? parse_infix_comparison : parse_prefix_comparison
72
+ end
73
+
74
+ # Parses a prefix comparison, i.e. without an explicit field: <operator> <value>
75
+ def parse_prefix_comparison
76
+ return ScopedSearch::QueryLanguage::AST::OperatorNode.new(next_token, [parse_value])
77
+ end
78
+
79
+ # Parses an infix expression, i.e. <field> <operator> <value>
80
+ def parse_infix_comparison
81
+ lhs = parse_value
82
+ return case peek_token
83
+ when nil
84
+ lhs
85
+ when :comma
86
+ next_token # skip comma
87
+ lhs
88
+ else
89
+ if COMPARISON_OPERATORS.include?(peek_token)
90
+ comparison_operator = next_token
91
+ rhs = parse_value
92
+ ScopedSearch::QueryLanguage::AST::OperatorNode.new(comparison_operator, [lhs, rhs])
93
+ else
94
+ lhs
95
+ end
96
+ end
97
+ end
98
+
99
+ # Parses a single value.
100
+ # This can either be a constant value or a field name.
101
+ def parse_value
102
+ raise ScopedSearch::QueryNotSupported, "Value expected but found #{peek_token.inspect}" unless String === peek_token
103
+ ScopedSearch::QueryLanguage::AST::LeafNode.new(next_token)
104
+ end
105
+
106
+ protected
107
+
108
+ def current_token # :nodoc:
109
+ @current_token
110
+ end
111
+
112
+ def peek_token(amount = 1) # :nodoc:
113
+ @tokens[amount - 1]
114
+ end
115
+
116
+ def next_token # :nodoc:
117
+ @current_token = @tokens.shift
118
+ end
119
+
120
+ end
@@ -0,0 +1,78 @@
1
+ # The Tokenizer module adds methods to the query language compiler that transforms a query string
2
+ # into a stream of tokens, which are more appropriate for parsing a query string.
3
+ module ScopedSearch::QueryLanguage::Tokenizer
4
+
5
+ # All keywords that the language supports
6
+ KEYWORDS = { 'and' => :and, 'or' => :or, 'not' => :not, 'set?' => :notnull, 'null?' => :null }
7
+
8
+ # Every operator the language supports.
9
+ OPERATORS = { '&' => :and, '|' => :or, '&&' => :and, '||' => :or, '-'=> :not, '!' => :not, '~' => :like, '!~' => :unlike,
10
+ '=' => :eq, '==' => :eq, '!=' => :ne, '<>' => :ne, '>' => :gt, '<' => :lt, '>=' => :gte, '<=' => :lte }
11
+
12
+ # Tokenizes the string and returns the result as an array of tokens.
13
+ def tokenize
14
+ @current_char_pos = -1
15
+ to_a
16
+ end
17
+
18
+ # Returns the current character of the string
19
+ def current_char
20
+ @current_char
21
+ end
22
+
23
+ # Returns a following character of the string (by default, the next
24
+ # character), without updating the position pointer.
25
+ def peek_char(amount = 1)
26
+ @str[@current_char_pos + amount, 1]
27
+ end
28
+
29
+ # Returns the next character of the string, and moves the position
30
+ # pointer one step forward
31
+ def next_char
32
+ @current_char_pos += 1
33
+ @current_char = @str[@current_char_pos, 1]
34
+ end
35
+
36
+ # Tokenizes the string by iterating over the characters.
37
+ def each_token(&block)
38
+ while next_char
39
+ case current_char
40
+ when /^\s?$/; # ignore
41
+ when '('; yield(:lparen)
42
+ when ')'; yield(:rparen)
43
+ when ','; yield(:comma)
44
+ when /\&|\||=|<|>|!|~|-/; tokenize_operator(&block)
45
+ when '"'; tokenize_quoted_keyword(&block)
46
+ else; tokenize_keyword(&block)
47
+ end
48
+ end
49
+ end
50
+
51
+ # Tokenizes an operator that occurs in the OPERATORS hash
52
+ def tokenize_operator(&block)
53
+ operator = current_char
54
+ operator << next_char if OPERATORS.has_key?(operator + peek_char)
55
+ yield(OPERATORS[operator])
56
+ end
57
+
58
+ # Tokenizes a keyword, and converts it to a Symbol if it is recognized as a
59
+ # reserved language keyword (the KEYWORDS array).
60
+ def tokenize_keyword(&block)
61
+ keyword = current_char
62
+ keyword << next_char while /[^=<>\s\&\|\)\(,]/ =~ peek_char
63
+ KEYWORDS.has_key?(keyword.downcase) ? yield(KEYWORDS[keyword.downcase]) : yield(keyword)
64
+ end
65
+
66
+ # Tokenizes a keyword that is quoted using double quotes. Allows escaping
67
+ # of double quote characters by backslashes.
68
+ def tokenize_quoted_keyword(&block)
69
+ keyword = ""
70
+ until next_char.nil? || current_char == '"'
71
+ keyword << (current_char == "\\" ? next_char : current_char)
72
+ end
73
+ yield(keyword)
74
+ end
75
+
76
+ alias :each :each_token
77
+
78
+ end
@@ -0,0 +1,32 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'scoped_search'
3
+
4
+ # Do not change the version and date fields by hand. This will be done
5
+ # automatically by the gem release script.
6
+ s.version = "2.0.1"
7
+ s.date = "2009-10-02"
8
+
9
+ s.summary = "A Rails plugin to search your models with a simple query language, implemented using a named_scope"
10
+ s.description = <<EOS
11
+ Scoped search makes it easy to search your ActiveRecord-based models.
12
+ It will create a named scope :search_for that can be called with a query string. It will build an SQL query using
13
+ the provided query string and a definition that specifies on what fields to search. Because the functionality is
14
+ built on named_scope, the result of the search_for call can be used like any other named_scope, so it can be
15
+ chained with another scope or combined with will_paginate."
16
+ EOS
17
+
18
+ s.authors = ['Willem van Bergen', 'Wes Hays']
19
+ s.email = ['willem@vanbergen.org', 'weshays@gbdev.com']
20
+ s.homepage = 'http://wiki.github.com/wvanbergen/scoped_search'
21
+
22
+ s.add_runtime_dependency('activerecord', '>= 2.1.0')
23
+ s.add_development_dependency('rspec', '>= 1.1.4')
24
+
25
+ s.rdoc_options << '--title' << s.name << '--main' << 'README.rdoc' << '--line-numbers' << '--inline-source'
26
+ s.extra_rdoc_files = ['README.rdoc']
27
+
28
+ # Do not change the files and test_files fields by hand. This will be done
29
+ # automatically by the gem release script.
30
+ s.files = %w(spec/spec_helper.rb spec/integration/string_querying_spec.rb spec/integration/relation_querying_spec.rb .gitignore spec/lib/mocks.rb scoped_search.gemspec lib/scoped_search/query_language/parser.rb LICENSE spec/lib/matchers.rb lib/scoped_search/definition.rb init.rb spec/unit/tokenizer_spec.rb spec/unit/parser_spec.rb spec/unit/ast_spec.rb lib/scoped_search/query_language/ast.rb spec/lib/database.rb Rakefile tasks/github-gem.rake spec/unit/query_builder_spec.rb lib/scoped_search/query_language.rb lib/scoped_search/query_builder.rb README.rdoc spec/unit/definition_spec.rb spec/database.yml spec/integration/api_spec.rb spec/integration/ordinal_querying_spec.rb lib/scoped_search/query_language/tokenizer.rb lib/scoped_search.rb)
31
+ s.test_files = %w(spec/integration/string_querying_spec.rb spec/integration/relation_querying_spec.rb spec/unit/tokenizer_spec.rb spec/unit/parser_spec.rb spec/unit/ast_spec.rb spec/unit/query_builder_spec.rb spec/unit/definition_spec.rb spec/integration/api_spec.rb spec/integration/ordinal_querying_spec.rb)
32
+ end
data/spec/database.yml ADDED
@@ -0,0 +1,25 @@
1
+ # rake spec will try to run the integration specs on all the following
2
+ # database connections to test the different adapters.
3
+ #
4
+ # The syntax of this file is similar to the database.yml file in a
5
+ # Rails application. You can change these parameters to your setup,
6
+ # or comment them out if you have no such database connections available
7
+ # to you.
8
+
9
+ sqlite3:
10
+ adapter: "sqlite3"
11
+ database: ":memory:"
12
+
13
+ mysql:
14
+ adapter: "mysql"
15
+ host: "localhost"
16
+ user: "root"
17
+ password:
18
+ database: "scoped_search_test"
19
+
20
+ postgresql:
21
+ adapter: "postgresql"
22
+ host: "localhost"
23
+ username: "scoped_search"
24
+ password: "scoped_search"
25
+ database: "scoped_search_test"
@@ -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