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