scoped_search 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ .DS_Store
2
+ scoped_search-*.gem
3
+ /tmp
4
+ /doc
5
+ /pkg
6
+ /coverage
7
+ /classes
8
+ /files
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Willem van Bergen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,107 @@
1
+ = Scoped search
2
+
3
+ The <b>scoped_search</b> Rails plugin makes it easy to search your ActiveRecord
4
+ models. Searching is performed using a query string, which should be passed to
5
+ the named_scope <tt>search_for</tt>. Based on a definition in what fields to
6
+ look, it will build query conditions and return those as a named scope.
7
+
8
+ Scoped search is great if you want to offer a simple search box to your users
9
+ and build a query based on the search string they enter. If you want to build a
10
+ more complex search form with multiple fields, searchlogic (see
11
+ http://github.com/binarylogic/searchlogic) may be a good choice for you.
12
+
13
+
14
+ == Installing
15
+
16
+ The recommended method to enable scoped_search in your project is adding the scoped_search gem to your environment. Add the following code to your Rails configuration in <tt>config/environment.rb</tt>:
17
+
18
+ Rails::Initializer.run do |config|
19
+ ...
20
+ config.gem 'wvanbergen-scoped_search', :lib => 'scoped_search',
21
+ :source => 'http://gems.github.com/'
22
+ end
23
+
24
+ Run <tt>sudo rake gems:install</tt> to install the gem.
25
+
26
+ Alternatively, install scoped_search as a Rails plugin:
27
+
28
+ script/plugin install git://github.com/wvanbergen/scoped_search.git
29
+
30
+ == Usage
31
+
32
+ Scoped search requires you to define the fields you want to search in:
33
+
34
+ class User < ActiveRecord::Base
35
+ scoped_search :on => [:first_name, :last_name]
36
+ end
37
+
38
+ For more information about options and using fields from relations, see the
39
+ project wiki on search definitions:
40
+ http://wiki.github.com/wvanbergen/scoped_search/search-definition
41
+
42
+ Now, the <b>search_for</b> scope is available for queries. You should pass a
43
+ query string to the scope. This can be empty or nil, in which case all no search
44
+ conditions are set (and all records will be returned).
45
+
46
+ User.search_for('my search string').each { |user| ... }
47
+
48
+ The result is returned as <tt>named_scope</tt>. Because of this, you can
49
+ actually chain the call with other scopes, or with will_paginate. An example:
50
+
51
+ class Project < ActiveRecord::Base
52
+ searchable_on :name, :description
53
+ named_scope :public, :conditions => {:public => true }
54
+ end
55
+
56
+ # using chained named_scopes and will_paginate in your controller
57
+ Project.public.search_for(params[:q]).paginate(:page => params[:page], :include => :tasks)
58
+
59
+ More information about usage can be found in the project wiki:
60
+ http://wiki.github.com/wvanbergen/scoped_search/usage
61
+
62
+ == Query language
63
+
64
+ The search query language is simple, but supports several constructs to support
65
+ more complex queries:
66
+
67
+ words:: require every word to be present, e.g.: <tt>some search keywords</tt>
68
+
69
+ phrases:: use quotes for multi-word phrases, e.g. <tt>"police car"</tt>
70
+
71
+ negation:: look for "everything but", e.g. <tt>police -uniform</tt>, <tt>-"police car"</tt>,
72
+ <tt>police NOT car</tt>
73
+
74
+ logical keywords:: make logical constructs using AND, OR, &&, ||, &, | operators, e.g.
75
+ <tt>uniform OR car</tt>, <tt>scoped && search</tt>
76
+
77
+ parentheses:: to structure logic e.g. <tt>"police AND (uniform OR car)"</tt>
78
+
79
+ comparison operators:: to search in numerical or temporal fields, e.g.
80
+ <tt>> 22</tt>, <tt>< 2009-01-01</tt>
81
+
82
+ explicit fields:: search only in the given field. e.g. <tt>username = root</tt>,
83
+ <tt>created_at > 2009-01-01</tt>
84
+
85
+ NULL checks:: using the <tt>set?</tt> and <tt>null?</tt> operator with a field name, e.g.
86
+ <tt>null? graduated_at</tt>, <tt>set? parent_id</tt>
87
+
88
+ A complex query example to look for Ruby on Rails programmers without cobol
89
+ experience, over 18 years old, with a recently updated record and a non-lame
90
+ nickname:
91
+
92
+ ("Ruby" OR "Rails") -cobol, age >= 18, updated_at > 2009-01-01 && nickname !~ l33t
93
+
94
+ For more info, see the the project wiki: http://wiki.github.com/wvanbergen/scoped_search/query-language
95
+
96
+ == Additional resources
97
+
98
+ * Source code: http://github.com/wvanbergen/scoped_search/tree/master
99
+ * Project wiki: http://wiki.github.com/wvanbergen/scoped_search
100
+ * RDoc documentation: http://wvanbergen.github.com/scoped_search
101
+ * wvanbergen's blog posts: http://techblog.floorplanner.com/tag/scoped_search
102
+
103
+ == License
104
+
105
+ This plugin is released under the MIT license. Please contact weshays
106
+ (http://github.com/weshays) or wvanbergen (http://github.com/wvanbergen) for any
107
+ questions.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ Dir['tasks/*.rake'].each { |file| load(file) }
2
+
3
+ GithubGem::RakeTasks.new(:gem)
4
+ task :default => [:spec]
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'scoped_search'
@@ -0,0 +1,91 @@
1
+ # ScopedSearch is the base module for the scoped_search plugin. This file
2
+ # defines some modules and exception classes, loads the necessary files, and
3
+ # installs itself in ActiveRecord.
4
+ #
5
+ # The ScopedSearch module defines two modules that can be mixed into
6
+ # ActiveRecord::Base as class methods. <tt>ScopedSearch::ClassMethods</tt>
7
+ # will register the scoped_search class function, which can be used to define
8
+ # the search fields. <tt>ScopedSearch::BackwardsCompatibility</tt> will
9
+ # register the <tt>searchable_on</tt> method for backwards compatibility with
10
+ # previous scoped_search versions (1.x).
11
+ module ScopedSearch
12
+
13
+ # The current scoped_search version. Do not change thisvalue by hand,
14
+ # because it will be updated automatically by the gem release script.
15
+ VERSION = "2.0.1"
16
+
17
+ # The ClassMethods module will be included into the ActiveRecord::Base class
18
+ # to add the <tt>ActiveRecord::Base.scoped_search</tt> method and the
19
+ # <tt>ActiveRecord::Base.search_for</tt> named scope.
20
+ module ClassMethods
21
+
22
+ # Export the scoped_search method fo defining the search options.
23
+ # This method will create a definition instance for the class if it does not yet exist,
24
+ # and use the object as block argument and retun value.
25
+ def scoped_search(*definitions)
26
+ @scoped_search ||= ScopedSearch::Definition.new(self)
27
+ definitions.each do |definition|
28
+ if definition[:on].kind_of?(Array)
29
+ definition[:on].each { |field| @scoped_search.define(definition.merge(:on => field)) }
30
+ else
31
+ @scoped_search.define(definition)
32
+ end
33
+ end
34
+ return @scoped_search
35
+ end
36
+ end
37
+
38
+ # The <tt>BackwardsCompatibility</tt> module can be included into
39
+ # <tt>ActiveRecord::Base</tt> to provide the <tt>searchable_on</tt> search
40
+ # field definition syntax that is compatible with scoped_seach 1.x
41
+ #
42
+ # Currently, it is included into <tt>ActiveRecord::Base</tt> by default, but
43
+ # this may change in the future. So, please uodate to the newer syntax as
44
+ # soon as possible.
45
+ module BackwardsCompatibility
46
+
47
+ # Defines fields to search on using a syntax compatible with scoped_search 1.x
48
+ def searchable_on(*fields)
49
+
50
+ options = fields.last.kind_of?(Hash) ? fields.pop : {}
51
+ # TODO: handle options?
52
+
53
+ fields.each do |field|
54
+ if relation = self.reflections.keys.detect { |relation| field.to_s =~ Regexp.new("^#{relation}_(\\w+)$") }
55
+ scoped_search(:in => relation, :on => $1.to_sym)
56
+ else
57
+ scoped_search(:on => field)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ # The default scoped_search exception class.
64
+ class Exception < StandardError
65
+ end
66
+
67
+ # The default exception class that is raised when there is something
68
+ # wrong with the scoped_search definition call.
69
+ #
70
+ # You usually do not want to catch this exception, but fix the faulty
71
+ # scoped_search method call.
72
+ class DefinitionError < ScopedSearch::Exception
73
+ end
74
+
75
+ # The default exception class that is raised when there is a problem
76
+ # with parsing or interpreting a search query.
77
+ #
78
+ # You may want to catch this exception and handle this gracefully.
79
+ class QueryNotSupported < ScopedSearch::Exception
80
+ end
81
+
82
+ end
83
+
84
+ # Load all lib files
85
+ require 'scoped_search/definition'
86
+ require 'scoped_search/query_language'
87
+ require 'scoped_search/query_builder'
88
+
89
+ # Import the search_on method in the ActiveReocrd::Base class
90
+ ActiveRecord::Base.send(:extend, ScopedSearch::ClassMethods)
91
+ ActiveRecord::Base.send(:extend, ScopedSearch::BackwardsCompatibility)
@@ -0,0 +1,145 @@
1
+ module ScopedSearch
2
+
3
+ # The ScopedSearch definition class defines on what fields should be search
4
+ # in the model in what cases
5
+ #
6
+ # A definition can be created by calling the <tt>scoped_search</tt> method on
7
+ # an ActiveRecord-based class, so you should not create an instance of this
8
+ # class yourself.
9
+ class Definition
10
+
11
+ # The Field class specifies a field of a model that is available for searching,
12
+ # in what cases this field should be searched and its default search behavior.
13
+ #
14
+ # Instances of this class are created when calling scoped_search in your model
15
+ # class, so you should not create instances of this class yourself.
16
+ class Field
17
+
18
+ attr_reader :definition, :field, :only_explicit, :relation
19
+
20
+ # The ActiveRecord-based class that belongs to this field.
21
+ def klass
22
+ if relation
23
+ definition.klass.reflections[relation].klass
24
+ else
25
+ definition.klass
26
+ end
27
+ end
28
+
29
+ # Returns the ActiveRecord column definition that corresponds to this field.
30
+ def column
31
+ klass.columns_hash[field.to_s]
32
+ end
33
+
34
+ # Returns the column type of this field.
35
+ def type
36
+ column.type
37
+ end
38
+
39
+ # Returns true if this field is a datetime-like column
40
+ def datetime?
41
+ [:datetime, :time, :timestamp].include?(type)
42
+ end
43
+
44
+ # Returns true if this field is a date-like column
45
+ def date?
46
+ type == :date
47
+ end
48
+
49
+ # Returns true if this field is a date or datetime-like column.
50
+ def temporal?
51
+ datetime? || date?
52
+ end
53
+
54
+ # Returns true if this field is numerical.
55
+ # Numerical means either integer, floating point or fixed point.
56
+ def numerical?
57
+ [:integer, :double, :float, :decimal].include?(type)
58
+ end
59
+
60
+ # Returns true if this is a textual column.
61
+ def textual?
62
+ [:string, :text].include?(type)
63
+ end
64
+
65
+ # Returns the default search operator for this field.
66
+ def default_operator
67
+ @default_operator ||= case type
68
+ when :string, :text then :like
69
+ else :eq
70
+ end
71
+ end
72
+
73
+ # Initializes a Field instance given the definition passed to the
74
+ # scoped_search call on the ActiveRecord-based model class.
75
+ def initialize(definition, options = {})
76
+ @definition = definition
77
+ case options
78
+ when Symbol, String
79
+ @field = field.to_sym
80
+ when Hash
81
+ @field = options.delete(:on)
82
+
83
+ # Set attributes from options hash
84
+ @relation = options[:in]
85
+ @only_explicit = !!options[:only_explicit]
86
+ @default_operator = options[:default_operator] if options.has_key?(:default_operator)
87
+ end
88
+
89
+ # Store this field is the field array
90
+ definition.fields[@field] ||= self
91
+ definition.unique_fields << self
92
+
93
+ # Store definition for alias / aliases as well
94
+ definition.fields[options[:alias]] ||= self if options[:alias]
95
+ options[:aliases].each { |al| definition.fields[al] ||= self } if options[:aliases]
96
+ end
97
+ end
98
+
99
+ attr_reader :klass, :fields, :unique_fields
100
+
101
+ # Initializes a ScopedSearch definition instance.
102
+ # This method will also setup a database adapter and create the :search_for
103
+ # named scope if it does not yet exist.
104
+ def initialize(klass)
105
+ @klass = klass
106
+ @fields = {}
107
+ @unique_fields = []
108
+
109
+ register_named_scope! unless klass.respond_to?(:search_for)
110
+ end
111
+
112
+ NUMERICAL_REGXP = /^\-?\d+(\.\d+)?$/
113
+
114
+ # Returns a list of appropriate fields to search in given a search keyword and operator.
115
+ def default_fields_for(value, operator = nil)
116
+
117
+ column_types = []
118
+ column_types += [:string, :text] if [nil, :like, :unlike, :ne, :eq].include?(operator)
119
+ column_types += [:integer, :double, :float, :decimal] if value =~ NUMERICAL_REGXP
120
+ column_types += [:datetime, :date, :timestamp] if (DateTime.parse(value) rescue nil)
121
+
122
+ default_fields.select { |field| column_types.include?(field.type) }
123
+ end
124
+
125
+ # Returns a list of fields that should be searched on by default.
126
+ #
127
+ # Every field will show up in this method's result, except for fields for
128
+ # which the only_explicit parameter is set to true.
129
+ def default_fields
130
+ unique_fields.reject { |field| field.only_explicit }
131
+ end
132
+
133
+ # Defines a new search field for this search definition.
134
+ def define(options)
135
+ Field.new(self, options)
136
+ end
137
+
138
+ protected
139
+
140
+ # Registers the search_for named scope within the class that is used for searching.
141
+ def register_named_scope! # :nodoc
142
+ @klass.named_scope(:search_for, lambda { |*args| ScopedSearch::QueryBuilder.build_query(args[1] || self, args[0]) })
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,296 @@
1
+ module ScopedSearch
2
+
3
+ # The QueryBuilder class builds an SQL query based on aquery string that is
4
+ # provided to the search_for named scope. It uses a SearchDefinition instance
5
+ # to shape the query.
6
+ class QueryBuilder
7
+
8
+ attr_reader :ast, :definition
9
+
10
+ # Creates a find parameter hash that can be passed to ActiveRecord::Base#find,
11
+ # given a search definition and query string. This method is called from the
12
+ # search_for named scope.
13
+ #
14
+ # This method will parse the query string and build an SQL query using the search
15
+ # query. It will return an ampty hash if the search query is empty, in which case
16
+ # the scope call will simply return all records.
17
+ def self.build_query(definition, query)
18
+ query_builder_class = self.class_for(definition)
19
+ if query.kind_of?(ScopedSearch::QueryLanguage::AST::Node)
20
+ return query_builder_class.new(definition, query).build_find_params
21
+ elsif query.kind_of?(String)
22
+ return query_builder_class.new(definition, ScopedSearch::QueryLanguage::Compiler.parse(query)).build_find_params
23
+ elsif query.nil?
24
+ return { }
25
+ else
26
+ raise "Unsupported query object: #{query.inspect}!"
27
+ end
28
+ end
29
+
30
+ # Loads the QueryBuilder class for the connection of the given definition.
31
+ # If no specific adapter is found, the default QueryBuilder class is returned.
32
+ def self.class_for(definition)
33
+ self.const_get(definition.klass.connection.class.name.split('::').last)
34
+ rescue
35
+ self
36
+ end
37
+
38
+ # Initializes the instance by setting the relevant parameters
39
+ def initialize(definition, ast)
40
+ @definition, @ast = definition, ast
41
+ end
42
+
43
+ # Actually builds the find parameters hash that should be used in the search_for
44
+ # named scope.
45
+ def build_find_params
46
+ parameters = []
47
+ includes = []
48
+
49
+ # Build SQL WHERE clause using the AST
50
+ sql = @ast.to_sql(self, definition) do |notification, value|
51
+
52
+ # Handle the notifications encountered during the SQL generation:
53
+ # Store the parameters, includes, etc so that they can be added to
54
+ # the find-hash later on.
55
+ case notification
56
+ when :parameter then parameters << value
57
+ when :include then includes << value
58
+ else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
59
+ end
60
+ end
61
+
62
+ # Build hash for ActiveRecord::Base#find for the named scope
63
+ find_attributes = {}
64
+ find_attributes[:conditions] = [sql] + parameters unless sql.nil?
65
+ find_attributes[:include] = includes.uniq unless includes.empty?
66
+ find_attributes # Uncomment for debugging
67
+ return find_attributes
68
+ end
69
+
70
+ # A hash that maps the operators of the query language with the corresponding SQL operator.
71
+ SQL_OPERATORS = { :eq =>'=', :ne => '<>', :like => 'LIKE', :unlike => 'NOT LIKE',
72
+ :gt => '>', :lt =>'<', :lte => '<=', :gte => '>=' }
73
+
74
+ # Return the SQL operator to use given an operator symbol and field definition.
75
+ #
76
+ # By default, it will simply look up the correct SQL operator in the SQL_OPERATORS
77
+ # hash, but this can be overrided by a database adapter.
78
+ def sql_operator(operator, field)
79
+ SQL_OPERATORS[operator]
80
+ end
81
+
82
+ # Perform a comparison between a field and a Date(Time) value.
83
+ #
84
+ # This function makes sure the date is valid and adjust the comparison in
85
+ # some cases to return more logical results.
86
+ #
87
+ # This function needs a block that can be used to pass other information about the query
88
+ # (parameters that should be escaped, includes) to the query builder.
89
+ #
90
+ # <tt>field</tt>:: The field to test.
91
+ # <tt>operator</tt>:: The operator used for comparison.
92
+ # <tt>value</tt>:: The value to compare the field with.
93
+ def datetime_test(field, operator, value, &block) # :yields: finder_option_type, value
94
+
95
+ # Parse the value as a date/time and ignore invalid timestamps
96
+ timestamp = parse_temporal(value)
97
+ return nil unless timestamp
98
+ timestamp = Date.parse(timestamp.strftime('%Y-%m-%d')) if field.date?
99
+
100
+ # Check for the case that a date-only value is given as search keyword,
101
+ # but the field is of datetime type. Change the comparison to return
102
+ # more logical results.
103
+ if timestamp.day_fraction == 0 && field.datetime?
104
+
105
+ if [:eq, :ne].include?(operator)
106
+ # Instead of looking for an exact (non-)match, look for dates that
107
+ # fall inside/outside the range of timestamps of that day.
108
+ yield(:parameter, timestamp)
109
+ yield(:parameter, timestamp + 1)
110
+ negate = (operator == :ne) ? 'NOT' : ''
111
+ field_sql = field.to_sql(operator, &block)
112
+ return "#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)"
113
+
114
+ elsif operator == :gt
115
+ # Make sure timestamps on the given date are not included in the results
116
+ # by moving the date to the next day.
117
+ timestamp += 1
118
+ operator = :gte
119
+
120
+ elsif operator == :lte
121
+ # Make sure the timestamps of the given date are included by moving the
122
+ # date to the next date.
123
+ timestamp += 1
124
+ operator = :lt
125
+ end
126
+ end
127
+
128
+ # Yield the timestamp and return the SQL test
129
+ yield(:parameter, timestamp)
130
+ "#{field.to_sql(operator, &block)} #{sql_operator(operator, field)} ?"
131
+ end
132
+
133
+ # Generates a simple SQL test expression, for a field and value using an operator.
134
+ #
135
+ # This function needs a block that can be used to pass other information about the query
136
+ # (parameters that should be escaped, includes) to the query builder.
137
+ #
138
+ # <tt>field</tt>:: The field to test.
139
+ # <tt>operator</tt>:: The operator used for comparison.
140
+ # <tt>value</tt>:: The value to compare the field with.
141
+ def sql_test(field, operator, value, &block) # :yields: finder_option_type, value
142
+ if [:like, :unlike].include?(operator) && value !~ /^\%/ && value !~ /\%$/
143
+ yield(:parameter, "%#{value}%")
144
+ return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
145
+ elsif field.temporal?
146
+ return datetime_test(field, operator, value, &block)
147
+ else
148
+ yield(:parameter, value)
149
+ return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
150
+ end
151
+ end
152
+
153
+ # Try to parse a string as a datetime.
154
+ def parse_temporal(value)
155
+ DateTime.parse(value, true) rescue nil
156
+ end
157
+
158
+ # This module gets included into the Field class to add SQL generation.
159
+ module Field
160
+
161
+ # Return an SQL representation for this field. Also make sure that
162
+ # the relation which includes the search field is included in the
163
+ # SQL query.
164
+ #
165
+ # This function may yield an :include that should be used in the
166
+ # ActiveRecord::Base#find call, to make sure that the field is avalable
167
+ # for the SQL query.
168
+ def to_sql(builder, operator = nil, &block) # :yields: finder_option_type, value
169
+ yield(:include, relation) if relation
170
+ definition.klass.connection.quote_table_name(klass.table_name) + "." +
171
+ definition.klass.connection.quote_column_name(field)
172
+ end
173
+ end
174
+
175
+ # This module contains modules for every AST::Node class to add SQL generation.
176
+ module AST
177
+
178
+ # Defines the to_sql method for AST LeadNodes
179
+ module LeafNode
180
+ def to_sql(builder, definition, &block)
181
+ # Search keywords found without context, just search on all the default fields
182
+ fragments = definition.default_fields_for(value).map do |field|
183
+ builder.sql_test(field, field.default_operator, value, &block)
184
+ end
185
+ "(#{fragments.join(' OR ')})"
186
+ end
187
+ end
188
+
189
+ # Defines the to_sql method for AST operator nodes
190
+ module OperatorNode
191
+
192
+ # Returns a NOT(...) SQL fragment that negates the current AST node's children
193
+ def to_not_sql(builder, definition, &block)
194
+ "(NOT(#{rhs.to_sql(builder, definition, &block)}) OR #{rhs.to_sql(builder, definition, &block)} IS NULL)"
195
+ end
196
+
197
+ # Returns an IS (NOT) NULL SQL fragment
198
+ def to_null_sql(builder, definition, &block)
199
+ field = definition.fields[rhs.value.to_sym]
200
+ raise ScopedSearch::QueryNotSupported, "Field '#{rhs.value}' not recognized for searching!" unless field
201
+
202
+ case operator
203
+ when :null then "#{field.to_sql(builder, &block)} IS NULL"
204
+ when :notnull then "#{field.to_sql(builder, &block)} IS NOT NULL"
205
+ end
206
+ end
207
+
208
+ # No explicit field name given, run the operator on all default fields
209
+ def to_default_fields_sql(builder, definition, &block)
210
+ raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
211
+
212
+ # Search keywords found without context, just search on all the default fields
213
+ fragments = definition.default_fields_for(rhs.value, operator).map { |field|
214
+ builder.sql_test(field, operator, rhs.value, &block) }.compact
215
+
216
+ fragments.empty? ? nil : "(#{fragments.join(' OR ')})"
217
+ end
218
+
219
+ # Explicit field name given, run the operator on the specified field only
220
+ def to_single_field_sql(builder, definition, &block)
221
+ raise ScopedSearch::QueryNotSupported, "Field name not a leaf node" unless lhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
222
+ raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
223
+
224
+ # Search only on the given field.
225
+ field = definition.fields[lhs.value.to_sym]
226
+ raise ScopedSearch::QueryNotSupported, "Field '#{lhs.value}' not recognized for searching!" unless field
227
+ builder.sql_test(field, operator, rhs.value, &block)
228
+ end
229
+
230
+ # Convert this AST node to an SQL fragment.
231
+ def to_sql(builder, definition, &block)
232
+ if operator == :not && children.length == 1
233
+ to_not_sql(builder, definition, &block)
234
+ elsif [:null, :notnull].include?(operator)
235
+ to_null_sql(builder, definition, &block)
236
+ elsif children.length == 1
237
+ to_default_fields_sql(builder, definition, &block)
238
+ elsif children.length == 2
239
+ to_single_field_sql(builder, definition, &block)
240
+ else
241
+ raise ScopedSearch::QueryNotSupported, "Don't know how to handle this operator node: #{operator.inspect} with #{children.inspect}!"
242
+ end
243
+ end
244
+ end
245
+
246
+ # Defines the to_sql method for AST AND/OR operators
247
+ module LogicalOperatorNode
248
+ def to_sql(builder, definition, &block)
249
+ fragments = children.map { |c| c.to_sql(builder, definition, &block) }.compact
250
+ fragments.empty? ? nil : "(#{fragments.join(" #{operator.to_s.upcase} ")})"
251
+ end
252
+ end
253
+ end
254
+
255
+ # The MysqlAdapter makes sure that case sensitive comparisons are used
256
+ # when using the (not) equals operator, regardless of the field's
257
+ # collation setting.
258
+ class MysqlAdapter < ScopedSearch::QueryBuilder
259
+
260
+ # Patches the default <tt>sql_operator</tt> method to add
261
+ # <tt>BINARY</tt> after the equals and not equals operator to force
262
+ # case-sensitive comparisons.
263
+ def sql_operator(operator, field)
264
+ if [:ne, :eq].include?(operator) && field.textual?
265
+ "#{SQL_OPERATORS[operator]} BINARY"
266
+ else
267
+ super(operator, field)
268
+ end
269
+ end
270
+ end
271
+
272
+ # The PostgreSQLAdapter make sure that searches are case sensitive when
273
+ # using the like/unlike operators, by using the PostrgeSQL-specific
274
+ # <tt>ILIKE operator</tt> instead of <tt>LIKE</tt>.
275
+ class PostgreSQLAdapter < ScopedSearch::QueryBuilder
276
+
277
+ # Switches out the default LIKE operator for ILIKE in the default
278
+ # <tt>sql_operator</tt> method.
279
+ def sql_operator(operator, field)
280
+ case operator
281
+ when :like then 'ILIKE'
282
+ when :unlike then 'NOT ILIKE'
283
+ else super(operator, field)
284
+ end
285
+ end
286
+ end
287
+ end
288
+
289
+ # Include the modules into the corresponding classes
290
+ # to add SQL generation capabilities to them.
291
+
292
+ Definition::Field.send(:include, QueryBuilder::Field)
293
+ QueryLanguage::AST::LeafNode.send(:include, QueryBuilder::AST::LeafNode)
294
+ QueryLanguage::AST::OperatorNode.send(:include, QueryBuilder::AST::OperatorNode)
295
+ QueryLanguage::AST::LogicalOperatorNode.send(:include, QueryBuilder::AST::LogicalOperatorNode)
296
+ end