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.
Files changed (38) hide show
  1. data/README.rdoc +48 -32
  2. data/Rakefile +1 -3
  3. data/lib/scoped_search.rb +45 -95
  4. data/lib/scoped_search/adapters.rb +41 -0
  5. data/lib/scoped_search/definition.rb +122 -0
  6. data/lib/scoped_search/query_builder.rb +213 -0
  7. data/lib/scoped_search/query_language.rb +30 -0
  8. data/lib/scoped_search/query_language/ast.rb +141 -0
  9. data/lib/scoped_search/query_language/parser.rb +115 -0
  10. data/lib/scoped_search/query_language/tokenizer.rb +62 -0
  11. data/{test → spec}/database.yml +0 -0
  12. data/spec/integration/api_spec.rb +82 -0
  13. data/spec/integration/ordinal_querying_spec.rb +153 -0
  14. data/spec/integration/relation_querying_spec.rb +258 -0
  15. data/spec/integration/string_querying_spec.rb +187 -0
  16. data/spec/lib/database.rb +44 -0
  17. data/spec/lib/matchers.rb +40 -0
  18. data/spec/lib/mocks.rb +19 -0
  19. data/spec/spec_helper.rb +21 -0
  20. data/spec/unit/ast_spec.rb +197 -0
  21. data/spec/unit/definition_spec.rb +24 -0
  22. data/spec/unit/parser_spec.rb +105 -0
  23. data/spec/unit/query_builder_spec.rb +5 -0
  24. data/spec/unit/tokenizer_spec.rb +97 -0
  25. data/tasks/database_tests.rake +5 -5
  26. data/tasks/github-gem.rake +8 -3
  27. metadata +39 -23
  28. data/lib/scoped_search/query_conditions_builder.rb +0 -209
  29. data/lib/scoped_search/query_language_parser.rb +0 -117
  30. data/lib/scoped_search/reg_tokens.rb +0 -51
  31. data/tasks/documentation.rake +0 -33
  32. data/test/integration/api_test.rb +0 -53
  33. data/test/lib/test_models.rb +0 -148
  34. data/test/lib/test_schema.rb +0 -68
  35. data/test/test_helper.rb +0 -44
  36. data/test/unit/query_conditions_builder_test.rb +0 -410
  37. data/test/unit/query_language_test.rb +0 -155
  38. data/test/unit/search_for_test.rb +0 -124
data/README.rdoc CHANGED
@@ -1,9 +1,8 @@
1
- = scoped_search
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 *search_for* that uses SQL
5
- <tt>LIKE %keyword%</tt> conditions for searching (ILIKE for Postgres). You can specify what fields
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
- Another alternative is to install scoped_search as a Rails plugin:
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
- First, you have to specify in what columns should be searched:
25
+ Scoped search requires you to define the fields you want to search in:
27
26
 
28
27
  class User < ActiveRecord::Base
29
- searchable_on :first_name, :last_name
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(params[:q]).each { |project| ... }
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
- You can also search on associate models. This works with <b>belongs_to</b>, <b>has_one</b>, <b>has_many</b>,
40
- <b>has_many :through</b>, and <b>HABTM</b>. For example if a User <b>has_many</b> Notes (title, content, created_at, updated_at)
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
- The search query language is simple. It supports these constructs:
48
- * <b>words:</b> <tt>some search keywords</tt>
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
- This functionality is build on <tt>named_scope</tt>. The searchable_on statement creates
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
- class Project < ActiveRecord::Base
61
- searchable_on :name, :description
62
- named_scope :accessible_by, lambda { |user| ... }
63
- end
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
- # using chained named_scopes and will_paginate
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
@@ -1,5 +1,3 @@
1
1
  Dir['tasks/*.rake'].each { |file| load(file) }
2
2
 
3
- desc 'Default: run unit tests for only sqlite.'
4
- task :default => [:test]
5
-
3
+ task :default => [:spec]
data/lib/scoped_search.rb CHANGED
@@ -1,108 +1,58 @@
1
1
  module ScopedSearch
2
-
2
+
3
3
  module ClassMethods
4
4
 
5
- def self.extended(base) # :nodoc:
6
- require 'scoped_search/reg_tokens'
7
- require 'scoped_search/query_language_parser'
8
- require 'scoped_search/query_conditions_builder'
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
- # Creates a named scope in the class it was called upon.
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
- # Make sure that the table to be searched actually exists
16
- if self.table_exists?
17
-
18
- # Get a collection of fields to be searched on.
19
- if fields.first.class.to_s == 'Hash'
20
- if fields.first.has_key?(:only)
21
- # only search on these fields.
22
- fields = fields.first[:only]
23
- elsif fields.first.has_key?(:except)
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
- # Build a hash that is used for the named_scope search_for.
71
- # This function will split the search_string into keywords, and search for all the keywords
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
- return retVal
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
- ActiveRecord::Base.send(:extend, ScopedSearch::ClassMethods)
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