wvanbergen-scoped_search 1.2.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +48 -32
- data/Rakefile +1 -3
- data/lib/scoped_search.rb +45 -95
- data/lib/scoped_search/adapters.rb +41 -0
- data/lib/scoped_search/definition.rb +122 -0
- data/lib/scoped_search/query_builder.rb +213 -0
- data/lib/scoped_search/query_language.rb +30 -0
- data/lib/scoped_search/query_language/ast.rb +141 -0
- data/lib/scoped_search/query_language/parser.rb +115 -0
- data/lib/scoped_search/query_language/tokenizer.rb +62 -0
- data/{test → spec}/database.yml +0 -0
- data/spec/integration/api_spec.rb +82 -0
- data/spec/integration/ordinal_querying_spec.rb +153 -0
- data/spec/integration/relation_querying_spec.rb +258 -0
- data/spec/integration/string_querying_spec.rb +187 -0
- data/spec/lib/database.rb +44 -0
- data/spec/lib/matchers.rb +40 -0
- data/spec/lib/mocks.rb +19 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/unit/ast_spec.rb +197 -0
- data/spec/unit/definition_spec.rb +24 -0
- data/spec/unit/parser_spec.rb +105 -0
- data/spec/unit/query_builder_spec.rb +5 -0
- data/spec/unit/tokenizer_spec.rb +97 -0
- data/tasks/database_tests.rake +5 -5
- data/tasks/github-gem.rake +8 -3
- metadata +39 -23
- data/lib/scoped_search/query_conditions_builder.rb +0 -209
- data/lib/scoped_search/query_language_parser.rb +0 -117
- data/lib/scoped_search/reg_tokens.rb +0 -51
- data/tasks/documentation.rake +0 -33
- data/test/integration/api_test.rb +0 -53
- data/test/lib/test_models.rb +0 -148
- data/test/lib/test_schema.rb +0 -68
- data/test/test_helper.rb +0 -44
- data/test/unit/query_conditions_builder_test.rb +0 -410
- data/test/unit/query_language_test.rb +0 -155
- data/test/unit/search_for_test.rb +0 -124
data/README.rdoc
CHANGED
@@ -1,9 +1,8 @@
|
|
1
|
-
=
|
1
|
+
= Scoped search
|
2
2
|
|
3
3
|
The <b>scoped_search</b> Rails plugin makes it easy to search your ActiveRecord models. Searching is
|
4
|
-
performed using a query string, which should be passed to the named_scope
|
5
|
-
|
6
|
-
should be used for searching.
|
4
|
+
performed using a query string, which should be passed to the named_scope <tt>search_for</tt>. Based on
|
5
|
+
a definition in what fields to look, it will build query conditions and return those as a named scope.
|
7
6
|
|
8
7
|
== Installing
|
9
8
|
|
@@ -12,58 +11,75 @@ The recommended method to enable scoped_search in your project is adding the sco
|
|
12
11
|
Rails::Initializer.run do |config|
|
13
12
|
...
|
14
13
|
config.gem 'wvanbergen-scoped_search', :lib => 'scoped_search',
|
15
|
-
source => 'http://gems.github.com/'
|
14
|
+
:source => 'http://gems.github.com/'
|
16
15
|
end
|
17
16
|
|
18
17
|
Run <tt>sudo rake gems:install</tt> to install the gem.
|
19
18
|
|
20
|
-
|
19
|
+
Alternatively, install scoped_search as a Rails plugin:
|
21
20
|
|
22
21
|
script/plugin install git://github.com/wvanbergen/scoped_search.git
|
23
22
|
|
24
23
|
== Usage
|
25
24
|
|
26
|
-
|
25
|
+
Scoped search requires you to define the fields you want to search in:
|
27
26
|
|
28
27
|
class User < ActiveRecord::Base
|
29
|
-
|
28
|
+
scoped_search :on => [:first_name, :last_name]
|
30
29
|
end
|
31
30
|
|
31
|
+
For more information about options and using fields from relations, see the project
|
32
|
+
wiki on search definitions: http://wiki.github.com/wvanbergen/scoped_search/search-definition
|
32
33
|
|
33
34
|
Now, the <b>search_for</b> scope is available for queries. You should pass a query string to the scope.
|
34
35
|
This can be empty or nil, in which case all no search conditions are set (and all records will be returned).
|
35
36
|
|
36
|
-
User.search_for(
|
37
|
+
User.search_for('my search string').each { |user| ... }
|
37
38
|
|
39
|
+
The result is returned as <tt>named_scope</tt>. Because of this, you can actually chain the call with
|
40
|
+
other scopes, or with will_paginate. An example:
|
38
41
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
class User < ActiveRecord::Base
|
43
|
-
has_many: notes
|
44
|
-
searchable_on :first_name, :last_name, :notes_title, :notes_content
|
42
|
+
class Project < ActiveRecord::Base
|
43
|
+
searchable_on :name, :description
|
44
|
+
named_scope :public, :conditions => {:public => true }
|
45
45
|
end
|
46
46
|
|
47
|
-
|
48
|
-
|
49
|
-
* <b>phrases:</b> <tt>"a single search phrase"</tt>
|
50
|
-
* <b>negation:</b> <tt>"look for this" -"but do not look for this phrase and this" -word</tt>
|
51
|
-
* <b>OR words/phrases:</b> word/phrase OR word/phrase. Example: <tt>"Hello World" OR "Hello Moon"</tt>
|
52
|
-
* <b>dates:</b> mm/dd/yyyy, dd/mm/yyyy, yyyy/mm/dd, yyyy-mm-dd
|
53
|
-
* <b>date ranges:</b> > date, >= date, < date, <= date, date TO date. Examples: <tt>> 30/05/1983</tt>, <tt>< 2009-01-30</tt>
|
47
|
+
# using chained named_scopes and will_paginate in your controller
|
48
|
+
Project.public.search_for(params[:q]).paginate(:page => params[:page], :include => :tasks)
|
54
49
|
|
55
|
-
|
56
|
-
a named_scope *search_for*. Because of this, you can actually chain the call with
|
57
|
-
other scopes. For example, this can be very useful if you only want to search in
|
58
|
-
projects that are accessible by a given user.
|
50
|
+
More information about usage can be found in the project wiki: http://wiki.github.com/wvanbergen/scoped_search/usage
|
59
51
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
52
|
+
== Query language
|
53
|
+
|
54
|
+
The search query language is simple, but supports several constructs to support more complex queries:
|
55
|
+
|
56
|
+
words:: require very word to be present, e.g.: <tt>some search keywords</tt>
|
57
|
+
|
58
|
+
phrases:: use quotes for multi-word phrases, e.g. <tt>"police car"</tt>
|
59
|
+
|
60
|
+
negation:: look for "everything but", e.g. <tt>police -uniform</tt>, <tt>-"police car"</tt>,
|
61
|
+
<tt>police NOT car</tt>
|
62
|
+
|
63
|
+
logical keywords:: make logical constructs using AND, OR, &&, ||, &, | operators, e.g.
|
64
|
+
<tt>uniform OR car</tt>, <tt>scoped && search</tt>
|
65
|
+
|
66
|
+
parentheses:: to structure logic e.g. <tt>"police AND (uniform OR car)"</tt>
|
67
|
+
|
68
|
+
comparison operators:: to search in numerical or temporal fields, e.g.
|
69
|
+
<tt>> 22</tt>, <tt>< 2009-01-01</tt>
|
70
|
+
|
71
|
+
explicit fields:: search only in the given field. e.g. <tt>username = root</tt>,
|
72
|
+
<tt>created_at > 2009-01-01</tt>
|
73
|
+
|
74
|
+
NULL checks:: using the <tt>set?</tt> and <tt>null?</tt> operator with a field name, e.g.
|
75
|
+
<tt>null? graduated_at</tt>, <tt>set? parent_id</tt>
|
76
|
+
|
77
|
+
A complex query example to look for Ruby on Rails programmers without cobol experience, over 18 years old,
|
78
|
+
with a recently updated record and a non-lame nickname:
|
79
|
+
|
80
|
+
("Ruby" OR "Rails") -cobol, age >= 18, updated_at > 2009-01-01 && nickname !~ l33t
|
64
81
|
|
65
|
-
|
66
|
-
Project.accessible_by(current_user).search_for(params[:q]).paginate(:page => params[:page], :include => :tasks)
|
82
|
+
For more info, see the the project wiki: http://wiki.github.com/wvanbergen/scoped_search/query-language
|
67
83
|
|
68
84
|
== Additional resources
|
69
85
|
|
data/Rakefile
CHANGED
data/lib/scoped_search.rb
CHANGED
@@ -1,108 +1,58 @@
|
|
1
1
|
module ScopedSearch
|
2
|
-
|
2
|
+
|
3
3
|
module ClassMethods
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
5
|
+
# Export the scoped_search method fo defining the search options.
|
6
|
+
# This method will create a definition instance for the class if it does not yet exist,
|
7
|
+
# and use the object as block argument and retun value.
|
8
|
+
def scoped_search(*definitions)
|
9
|
+
@scoped_search ||= ScopedSearch::Definition.new(self)
|
10
|
+
definitions.each do |definition|
|
11
|
+
if definition[:on].kind_of?(Array)
|
12
|
+
definition[:on].each { |field| @scoped_search.define(definition.merge(:on => field)) }
|
13
|
+
else
|
14
|
+
@scoped_search.define(definition)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
return @scoped_search
|
9
18
|
end
|
19
|
+
|
20
|
+
end
|
10
21
|
|
11
|
-
|
12
|
-
#
|
13
|
-
# fields:: The fields to search on.
|
22
|
+
module BackwardsCompatibility
|
23
|
+
# Defines fields to search on using a syntax compatible with scoped_search 1.2
|
14
24
|
def searchable_on(*fields)
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
# Get all the fields and remove any that are in the -except- list.
|
25
|
-
fields = self.column_names.collect { |column| fields.first[:except].include?(column.to_sym) ? nil : column.to_sym }.compact
|
26
|
-
end
|
25
|
+
|
26
|
+
options = fields.last.kind_of?(Hash) ? fields.pop : {}
|
27
|
+
# TODO: handle options?
|
28
|
+
|
29
|
+
fields.each do |field|
|
30
|
+
if relation = self.reflections.keys.detect { |relation| field.to_s =~ Regexp.new("^#{relation}_(\\w+)$") }
|
31
|
+
scoped_search(:in => relation, :on => $1.to_sym)
|
32
|
+
else
|
33
|
+
scoped_search(:on => field)
|
27
34
|
end
|
28
|
-
|
29
|
-
# Get an array of associate modules.
|
30
|
-
assoc_models = self.reflections.collect { |key,value| key }
|
31
|
-
|
32
|
-
# Subtract out the fields to be searched on that are part of *this* model.
|
33
|
-
# Any thing left will be associate module fields to be searched on.
|
34
|
-
assoc_fields = fields - self.column_names.collect { |column| column.to_sym }
|
35
|
-
|
36
|
-
# Subtraced out the associated fields from the fields so that you are only left
|
37
|
-
# with fields in *this* model.
|
38
|
-
fields -= assoc_fields
|
39
|
-
|
40
|
-
# Loop through each of the associate models and group accordingly each
|
41
|
-
# associate model field to search. Assuming the following relations:
|
42
|
-
# has_many :clients
|
43
|
-
# has_many :notes,
|
44
|
-
# belongs_to :user_type
|
45
|
-
# assoc_groupings will look like
|
46
|
-
# assoc_groupings = {:clients => [:first_name, :last_name],
|
47
|
-
# :notes => [:descr],
|
48
|
-
# :user_type => [:identifier]}
|
49
|
-
assoc_groupings = {}
|
50
|
-
assoc_models.each do |assoc_model|
|
51
|
-
assoc_groupings[assoc_model] = []
|
52
|
-
assoc_fields.each do |assoc_field|
|
53
|
-
unless assoc_field.to_s.match(/^#{assoc_model.to_s}_/).nil?
|
54
|
-
assoc_groupings[assoc_model] << assoc_field.to_s.sub(/^#{assoc_model.to_s}_/, '').to_sym
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
# If a grouping does not contain any fields to be searched on then remove it.
|
60
|
-
assoc_groupings = assoc_groupings.delete_if {|group, field_group| field_group.empty?}
|
61
|
-
|
62
|
-
# Set the appropriate class attributes.
|
63
|
-
self.cattr_accessor :scoped_search_fields, :scoped_search_assoc_groupings
|
64
|
-
self.scoped_search_fields = fields
|
65
|
-
self.scoped_search_assoc_groupings = assoc_groupings
|
66
|
-
self.named_scope :search_for, lambda { |keywords| self.build_scoped_search_conditions(keywords) }
|
67
35
|
end
|
68
|
-
end
|
36
|
+
end
|
37
|
+
end
|
69
38
|
|
70
|
-
|
71
|
-
|
72
|
-
# in the fields that were provided to searchable_on.
|
73
|
-
#
|
74
|
-
# search_string:: The search string to parse.
|
75
|
-
def build_scoped_search_conditions(search_string)
|
76
|
-
if search_string.nil? || search_string.strip.blank?
|
77
|
-
return {:conditions => nil}
|
78
|
-
else
|
79
|
-
query_fields = {}
|
80
|
-
self.scoped_search_fields.each do |field|
|
81
|
-
field_name = connection.quote_table_name(table_name) + "." + connection.quote_column_name(field)
|
82
|
-
query_fields[field_name] = self.columns_hash[field.to_s].type
|
83
|
-
end
|
84
|
-
|
85
|
-
assoc_model_indx = 0
|
86
|
-
assoc_fields_indx = 1
|
87
|
-
assoc_models_to_include = []
|
88
|
-
self.scoped_search_assoc_groupings.each do |group|
|
89
|
-
assoc_models_to_include << group[assoc_model_indx]
|
90
|
-
group[assoc_fields_indx].each do |group_field|
|
91
|
-
field_name = connection.quote_table_name(group[assoc_model_indx].to_s.pluralize) + "." + connection.quote_column_name(group_field)
|
92
|
-
query_fields[field_name] = self.reflections[group[assoc_model_indx]].klass.columns_hash[group_field.to_s].type
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
search_conditions = QueryLanguageParser.parse(search_string)
|
97
|
-
conditions = QueryConditionsBuilder.build_query(search_conditions, query_fields)
|
98
|
-
|
99
|
-
retVal = {:conditions => conditions}
|
100
|
-
retVal[:include] = assoc_models_to_include unless assoc_models_to_include.empty?
|
39
|
+
class Exception < StandardError
|
40
|
+
end
|
101
41
|
|
102
|
-
|
103
|
-
end
|
104
|
-
end
|
42
|
+
class DefinitionError < ScopedSearch::Exception
|
105
43
|
end
|
44
|
+
|
45
|
+
class QueryNotSupported < ScopedSearch::Exception
|
46
|
+
end
|
47
|
+
|
106
48
|
end
|
107
49
|
|
108
|
-
|
50
|
+
# Load all lib files
|
51
|
+
require 'scoped_search/definition'
|
52
|
+
require 'scoped_search/adapters'
|
53
|
+
require 'scoped_search/query_language'
|
54
|
+
require 'scoped_search/query_builder'
|
55
|
+
|
56
|
+
# Import the search_on method in the ActiveReocrd::Base class
|
57
|
+
ActiveRecord::Base.send(:extend, ScopedSearch::ClassMethods)
|
58
|
+
ActiveRecord::Base.send(:extend, ScopedSearch::BackwardsCompatibility)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module ScopedSearch
|
2
|
+
module Adapter
|
3
|
+
|
4
|
+
def self.setup(connection)
|
5
|
+
adapter = connection.class.name.split('::').last
|
6
|
+
const_get(adapter).setup(connection) if const_defined?(adapter)
|
7
|
+
end
|
8
|
+
|
9
|
+
module MysqlAdapter
|
10
|
+
|
11
|
+
def self.setup(connection)
|
12
|
+
ScopedSearch::Definition::Field.send :include, FieldInstanceMethods
|
13
|
+
end
|
14
|
+
|
15
|
+
module FieldInstanceMethods
|
16
|
+
|
17
|
+
# Monkey patch Field#to_sql method to ensure that comparisons using :eq / :ne
|
18
|
+
# are case sensitive by adding a BINARY operator in front of the field name.
|
19
|
+
def to_sql(operator = nil, &block)
|
20
|
+
|
21
|
+
# Normal implementation
|
22
|
+
yield(:include, relation) if relation
|
23
|
+
field_name = definition.klass.connection.quote_table_name(klass.table_name) + "." +
|
24
|
+
definition.klass.connection.quote_column_name(field)
|
25
|
+
|
26
|
+
# Add BINARY operator if the field is textual and = or <> is used.
|
27
|
+
field_name = "BINARY #{field_name}" if textual? && [:ne, :eq].include?(operator)
|
28
|
+
return field_name
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
module PostgreSQLAdapter
|
35
|
+
def self.setup(connection)
|
36
|
+
ScopedSearch::QueryBuilder::SQL_OPERATORS[:like] = 'ILIKE'
|
37
|
+
ScopedSearch::QueryBuilder::SQL_OPERATORS[:unlike] = 'NOT ILIKE'
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module ScopedSearch
|
2
|
+
|
3
|
+
class Definition
|
4
|
+
|
5
|
+
class Field
|
6
|
+
|
7
|
+
attr_reader :definition, :field, :only_explicit, :relation
|
8
|
+
|
9
|
+
def klass
|
10
|
+
if relation
|
11
|
+
definition.klass.reflections[relation].klass
|
12
|
+
else
|
13
|
+
definition.klass
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Find the relevant column definition in the AR class
|
18
|
+
def column
|
19
|
+
klass.columns_hash[field.to_s]
|
20
|
+
end
|
21
|
+
|
22
|
+
def type
|
23
|
+
column.type
|
24
|
+
end
|
25
|
+
|
26
|
+
def datetime?
|
27
|
+
[:datetime, :time, :timestamp].include?(type)
|
28
|
+
end
|
29
|
+
|
30
|
+
def date?
|
31
|
+
type == :date
|
32
|
+
end
|
33
|
+
|
34
|
+
def temporal?
|
35
|
+
datetime? || date?
|
36
|
+
end
|
37
|
+
|
38
|
+
def numerical?
|
39
|
+
[:integer, :double, :float, :decimal].include?(type)
|
40
|
+
end
|
41
|
+
|
42
|
+
def textual?
|
43
|
+
[:string, :text].include?(type)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns the default search operator for this field.
|
47
|
+
def default_operator
|
48
|
+
@default_operator ||= case type
|
49
|
+
when :string, :text then :like
|
50
|
+
else :eq
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(definition, options = {})
|
55
|
+
@definition = definition
|
56
|
+
case options
|
57
|
+
when Symbol, String
|
58
|
+
@field = field.to_sym
|
59
|
+
when Hash
|
60
|
+
@field = options.delete(:on)
|
61
|
+
|
62
|
+
# Set attributes from options hash
|
63
|
+
@relation = options[:in]
|
64
|
+
@only_explicit = !!options[:only_explicit]
|
65
|
+
@default_operator = options[:default_operator] if options.has_key?(:default_operator)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Store this field is the field array
|
69
|
+
definition.fields[@field] ||= self
|
70
|
+
definition.unique_fields << self
|
71
|
+
|
72
|
+
# Store definition for alias / aliases as well
|
73
|
+
definition.fields[options[:alias]] ||= self if options[:alias]
|
74
|
+
options[:aliases].each { |al| definition.fields[al] ||= self } if options[:aliases]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
attr_reader :klass, :fields, :unique_fields
|
79
|
+
|
80
|
+
def initialize(klass)
|
81
|
+
@klass = klass
|
82
|
+
@fields = {}
|
83
|
+
@unique_fields = []
|
84
|
+
|
85
|
+
setup_adapter! unless klass.connection.nil?
|
86
|
+
register_named_scope! unless klass.respond_to?(:search_for)
|
87
|
+
end
|
88
|
+
|
89
|
+
NUMBER_REGXP = /^\-?\d+(\.\d+)?$/
|
90
|
+
|
91
|
+
def default_fields_for(value, operator = nil)
|
92
|
+
# Use the value to detect
|
93
|
+
column_types = []
|
94
|
+
column_types += [:string, :text] if [nil, :like, :unlike, :ne, :eq].include?(operator)
|
95
|
+
column_types += [:integer, :double, :float, :decimal] if value =~ NUMBER_REGXP
|
96
|
+
column_types += [:datetime, :date, :timestamp] if ScopedSearch::QueryBuilder.parse_temporal(value)
|
97
|
+
|
98
|
+
default_fields.select { |field| column_types.include?(field.type) }
|
99
|
+
end
|
100
|
+
|
101
|
+
def default_fields
|
102
|
+
unique_fields.reject { |field| field.only_explicit }
|
103
|
+
end
|
104
|
+
|
105
|
+
def define(options)
|
106
|
+
Field.new(self, options)
|
107
|
+
end
|
108
|
+
|
109
|
+
protected
|
110
|
+
|
111
|
+
# Registers the search_for named scope within the class
|
112
|
+
def register_named_scope! # :nodoc
|
113
|
+
@klass.named_scope(:search_for, lambda { |*args| ScopedSearch::QueryBuilder.build_query(args[1] || self, args[0]) })
|
114
|
+
end
|
115
|
+
|
116
|
+
def setup_adapter!
|
117
|
+
ScopedSearch::Adapter.setup(@klass.connection)
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
module ScopedSearch
|
2
|
+
|
3
|
+
class QueryBuilder
|
4
|
+
|
5
|
+
attr_reader :ast, :definition
|
6
|
+
|
7
|
+
# Creates a find parameter hash given a class, and query string.
|
8
|
+
def self.build_query(definition, query)
|
9
|
+
# Return all record when an empty search string is given
|
10
|
+
if !query.kind_of?(String) || query.strip.blank?
|
11
|
+
return { :conditions => nil }
|
12
|
+
elsif query.kind_of?(ScopedSearch::QueryLanguage::AST::Node)
|
13
|
+
return self.new(definition, query).build_find_params
|
14
|
+
else
|
15
|
+
return self.new(definition, ScopedSearch::QueryLanguage::Compiler.parse(query)).build_find_params
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Initializes the instance by setting the relevant parameters
|
20
|
+
def initialize(definition, ast)
|
21
|
+
@definition, @ast = definition, ast
|
22
|
+
end
|
23
|
+
|
24
|
+
# Actually builds the find parameters
|
25
|
+
def build_find_params
|
26
|
+
parameters = []
|
27
|
+
includes = []
|
28
|
+
|
29
|
+
# Build SQL WHERE clause using the AST
|
30
|
+
sql = @ast.to_sql(definition) do |notification, value|
|
31
|
+
|
32
|
+
# Handle the notifications encountered during the SQL generation:
|
33
|
+
# Store the parameters, includes, etc so that they can be added to
|
34
|
+
# the find-hash later on.
|
35
|
+
case notification
|
36
|
+
when :parameter then parameters << value
|
37
|
+
when :include then includes << value
|
38
|
+
else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Build hash for ActiveRecord::Base#find for the named scope
|
43
|
+
find_attributes = {}
|
44
|
+
find_attributes[:conditions] = [sql] + parameters unless sql.nil?
|
45
|
+
find_attributes[:include] = includes.uniq unless includes.empty?
|
46
|
+
# p find_attributes # Uncomment for debugging
|
47
|
+
return find_attributes
|
48
|
+
end
|
49
|
+
|
50
|
+
SQL_OPERATORS = { :eq =>'=', :ne => '<>',
|
51
|
+
:like => 'LIKE', :unlike => 'NOT LIKE',
|
52
|
+
:gt => '>', :lt =>'<', :lte => '<=', :gte => '>=' }
|
53
|
+
|
54
|
+
# Return the SQL operator to use
|
55
|
+
def self.sql_operator(operator, field)
|
56
|
+
SQL_OPERATORS[operator]
|
57
|
+
end
|
58
|
+
|
59
|
+
# Perform a comparison between a field and a Date(Time) value.
|
60
|
+
# Makes sure the date is valid and adjust the comparison in
|
61
|
+
# some cases to return more logical results
|
62
|
+
def self.datetime_test(field, operator, value, &block)
|
63
|
+
|
64
|
+
# Parse the value as a date/time and ignore invalid timestamps
|
65
|
+
timestamp = parse_temporal(value)
|
66
|
+
return nil unless timestamp
|
67
|
+
timestamp = Date.parse(timestamp.strftime('%Y-%m-%d')) if field.date?
|
68
|
+
|
69
|
+
# Check for the case that a date-only value is given as search keyword,
|
70
|
+
# but the field is of datetime type. Change the comparison to return
|
71
|
+
# more logical results.
|
72
|
+
if timestamp.day_fraction == 0 && field.datetime?
|
73
|
+
|
74
|
+
if [:eq, :ne].include?(operator)
|
75
|
+
# Instead of looking for an exact (non-)match, look for dates that
|
76
|
+
# fall inside/outside the range of timestamps of that day.
|
77
|
+
yield(:parameter, timestamp)
|
78
|
+
yield(:parameter, timestamp + 1)
|
79
|
+
negate = (operator == :ne) ? 'NOT' : ''
|
80
|
+
field_sql = field.to_sql(operator, &block)
|
81
|
+
return "#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)"
|
82
|
+
|
83
|
+
elsif operator == :gt
|
84
|
+
# Make sure timestamps on the given date are not included in the results
|
85
|
+
# by moving the date to the next day.
|
86
|
+
timestamp += 1
|
87
|
+
operator = :gte
|
88
|
+
|
89
|
+
elsif operator == :lte
|
90
|
+
# Make sure the timestamps of the given date are included by moving the
|
91
|
+
# date to the next date.
|
92
|
+
timestamp += 1
|
93
|
+
operator = :lt
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Yield the timestamp and return the SQL test
|
98
|
+
yield(:parameter, timestamp)
|
99
|
+
"#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
|
100
|
+
end
|
101
|
+
|
102
|
+
# Generates a simple SQL test expression, for a field and value using an operator.
|
103
|
+
def self.sql_test(field, operator, value, &block)
|
104
|
+
if [:like, :unlike].include?(operator) && value !~ /^\%/ && value !~ /\%$/
|
105
|
+
yield(:parameter, "%#{value}%")
|
106
|
+
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
|
107
|
+
elsif field.temporal?
|
108
|
+
return datetime_test(field, operator, value, &block)
|
109
|
+
else
|
110
|
+
yield(:parameter, value)
|
111
|
+
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Try to parse a string as a datetime.
|
116
|
+
def self.parse_temporal(value)
|
117
|
+
DateTime.parse(value, true) rescue nil
|
118
|
+
end
|
119
|
+
|
120
|
+
module Field
|
121
|
+
|
122
|
+
# Return an SQL representation for this field
|
123
|
+
def to_sql(operator = nil, &block)
|
124
|
+
yield(:include, relation) if relation
|
125
|
+
definition.klass.connection.quote_table_name(klass.table_name) + "." +
|
126
|
+
definition.klass.connection.quote_column_name(field)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
module AST
|
131
|
+
|
132
|
+
# Defines the to_sql method for AST LeadNodes
|
133
|
+
module LeafNode
|
134
|
+
def to_sql(definition, &block)
|
135
|
+
# Search keywords found without context, just search on all the default fields
|
136
|
+
fragments = definition.default_fields_for(value).map do |field|
|
137
|
+
ScopedSearch::QueryBuilder.sql_test(field, field.default_operator, value, &block)
|
138
|
+
end
|
139
|
+
"(#{fragments.join(' OR ')})"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Defines the to_sql method for AST operator nodes
|
144
|
+
module OperatorNode
|
145
|
+
|
146
|
+
# Returns a NOT(...) SQL fragment that negates the current AST node's children
|
147
|
+
def to_not_sql(definition, &block)
|
148
|
+
"(NOT(#{rhs.to_sql(definition, &block)}) OR #{rhs.to_sql(definition, &block)} IS NULL)"
|
149
|
+
end
|
150
|
+
|
151
|
+
# Returns a IS (NOT) NULL SQL fragment
|
152
|
+
def to_null_sql(definition, &block)
|
153
|
+
field = definition.fields[rhs.value.to_sym]
|
154
|
+
raise ScopedSearch::QueryNotSupported, "Field '#{rhs.value}' not recognized for searching!" unless field
|
155
|
+
|
156
|
+
case operator
|
157
|
+
when :null then "#{field.to_sql(&block)} IS NULL"
|
158
|
+
when :notnull then "#{field.to_sql(&block)} IS NOT NULL"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# No explicit field name given, run the operator on all default fields
|
163
|
+
def to_default_fields_sql(definition, &block)
|
164
|
+
raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
|
165
|
+
|
166
|
+
# Search keywords found without context, just search on all the default fields
|
167
|
+
fragments = definition.default_fields_for(rhs.value, operator).map { |field|
|
168
|
+
ScopedSearch::QueryBuilder.sql_test(field, operator, rhs.value, &block) }.compact
|
169
|
+
fragments.empty? ? nil : "(#{fragments.join(' OR ')})"
|
170
|
+
end
|
171
|
+
|
172
|
+
# Explicit field name given, run the operator on the specified field only
|
173
|
+
def to_single_field_sql(definition, &block)
|
174
|
+
raise ScopedSearch::QueryNotSupported, "Field name not a leaf node" unless lhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
|
175
|
+
raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
|
176
|
+
|
177
|
+
# Search only on the given field.
|
178
|
+
field = definition.fields[lhs.value.to_sym]
|
179
|
+
raise ScopedSearch::QueryNotSupported, "Field '#{lhs.value}' not recognized for searching!" unless field
|
180
|
+
ScopedSearch::QueryBuilder.sql_test(field, operator, rhs.value, &block)
|
181
|
+
end
|
182
|
+
|
183
|
+
# Convert this AST node to an SQL fragment.
|
184
|
+
def to_sql(definition, &block)
|
185
|
+
if operator == :not && children.length == 1
|
186
|
+
to_not_sql(definition, &block)
|
187
|
+
elsif [:null, :notnull].include?(operator)
|
188
|
+
to_null_sql(definition, &block)
|
189
|
+
elsif children.length == 1
|
190
|
+
to_default_fields_sql(definition, &block)
|
191
|
+
elsif children.length == 2
|
192
|
+
to_single_field_sql(definition, &block)
|
193
|
+
else
|
194
|
+
raise ScopedSearch::QueryNotSupported, "Don't know how to handle this operator node: #{operator.inspect} with #{children.inspect}!"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Defines the to_sql method for AST AND/OR operators
|
200
|
+
module LogicalOperatorNode
|
201
|
+
def to_sql(definition, &block)
|
202
|
+
fragments = children.map { |c| c.to_sql(definition, &block) }.compact
|
203
|
+
fragments.empty? ? nil : "(#{fragments.join(" #{operator.to_s.upcase} ")})"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
Definition::Field.send(:include, QueryBuilder::Field)
|
210
|
+
QueryLanguage::AST::LeafNode.send(:include, QueryBuilder::AST::LeafNode)
|
211
|
+
QueryLanguage::AST::OperatorNode.send(:include, QueryBuilder::AST::OperatorNode)
|
212
|
+
QueryLanguage::AST::LogicalOperatorNode.send(:include, QueryBuilder::AST::LogicalOperatorNode)
|
213
|
+
end
|