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.
@@ -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