scoped_search 2.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/.gitignore
ADDED
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
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
|