module ScopedSearch
# The QueryBuilder class builds an SQL query based on aquery string that is
# provided to the search_for named scope. It uses a SearchDefinition instance
# to shape the query.
class QueryBuilder
attr_reader :ast, :definition
# Creates a find parameter hash that can be passed to ActiveRecord::Base#find,
# given a search definition and query string. This method is called from the
# search_for named scope.
#
# This method will parse the query string and build an SQL query using the search
# query. It will return an empty hash if the search query is empty, in which case
# the scope call will simply return all records.
def self.build_query(definition, *args)
query = args[0] ||=''
options = args[1] || {}
query_builder_class = self.class_for(definition)
if query.kind_of?(ScopedSearch::QueryLanguage::AST::Node)
return query_builder_class.new(definition, query, options[:profile]).build_find_params(options)
elsif query.kind_of?(String)
return query_builder_class.new(definition, ScopedSearch::QueryLanguage::Compiler.parse(query), options[:profile]).build_find_params(options)
else
raise "Unsupported query object: #{query.inspect}!"
end
end
# Loads the QueryBuilder class for the connection of the given definition.
# If no specific adapter is found, the default QueryBuilder class is returned.
def self.class_for(definition)
self.const_get(definition.klass.connection.class.name.split('::').last)
rescue
self
end
# Initializes the instance by setting the relevant parameters
def initialize(definition, ast, profile)
@definition, @ast, @definition.profile = definition, ast, profile
end
# Actually builds the find parameters hash that should be used in the search_for
# named scope.
def build_find_params(options)
keyconditions = []
keyparameters = []
parameters = []
includes = []
joins = []
# Build SQL WHERE clause using the AST
sql = @ast.to_sql(self, definition) do |notification, value|
# Handle the notifications encountered during the SQL generation:
# Store the parameters, includes, etc so that they can be added to
# the find-hash later on.
case notification
when :keycondition then keyconditions << value
when :keyparameter then keyparameters << value
when :parameter then parameters << value
when :include then includes << value
when :joins then joins << value
else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
end
end
# Build SQL ORDER BY clause
order = order_by(options[:order]) do |notification, value|
case notification
when :parameter then parameters << value
when :include then includes << value
when :joins then joins << value
else raise ScopedSearch::QueryNotSupported, "Cannot handle #{notification.inspect}: #{value.inspect}"
end
end
sql = (keyconditions + (sql.nil? ? [] : [sql]) ).map {|c| "(#{c})"}.join(" AND ")
# Build hash for ActiveRecord::Base#find for the named scope
find_attributes = {}
find_attributes[:conditions] = [sql] + keyparameters + parameters unless sql.blank?
find_attributes[:include] = includes.uniq unless includes.empty?
find_attributes[:joins] = joins.uniq unless joins.empty?
find_attributes[:order] = order unless order.nil?
find_attributes[:group] = options[:group] unless options[:group].nil?
# p find_attributes # Uncomment for debugging
return find_attributes
end
def order_by(order, &block)
order ||= definition.default_order
if order
field = definition.field_by_name(order.to_s.split(' ')[0])
raise ScopedSearch::QueryNotSupported, "the field '#{order.to_s.split(' ')[0]}' in the order statement is not valid field for search" unless field
sql = field.to_sql(&block)
direction = (order.to_s.downcase.include?('desc')) ? " DESC" : " ASC"
order = sql + direction
end
return order
end
# A hash that maps the operators of the query language with the corresponding SQL operator.
SQL_OPERATORS = { :eq =>'=', :ne => '<>', :like => 'LIKE', :unlike => 'NOT LIKE',
:gt => '>', :lt =>'<', :lte => '<=', :gte => '>=' }
# Return the SQL operator to use given an operator symbol and field definition.
#
# By default, it will simply look up the correct SQL operator in the SQL_OPERATORS
# hash, but this can be overridden by a database adapter.
def sql_operator(operator, field)
raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if [:like, :unlike].include?(operator) and !field.textual?
SQL_OPERATORS[operator]
end
# Returns a NOT (...) SQL fragment that negates the current AST node's children
def to_not_sql(rhs, definition, &block)
"NOT COALESCE(#{rhs.to_sql(self, definition, &block)}, 0)"
end
# Perform a comparison between a field and a Date(Time) value.
#
# This function makes sure the date is valid and adjust the comparison in
# some cases to return more logical results.
#
# This function needs a block that can be used to pass other information about the query
# (parameters that should be escaped, includes) to the query builder.
#
# field:: The field to test.
# operator:: The operator used for comparison.
# value:: The value to compare the field with.
def datetime_test(field, operator, value, &block) # :yields: finder_option_type, value
# Parse the value as a date/time and ignore invalid timestamps
timestamp = definition.parse_temporal(value)
return nil unless timestamp
timestamp = timestamp.to_date if field.date?
# Check for the case that a date-only value is given as search keyword,
# but the field is of datetime type. Change the comparison to return
# more logical results.
if field.datetime?
span = 1.minute if(value =~ /\A\s*\d+\s+\bminutes?\b\s+\bago\b\s*\z/i)
span ||= (timestamp.day_fraction == 0) ? 1.day : 1.hour
if [:eq, :ne].include?(operator)
# Instead of looking for an exact (non-)match, look for dates that
# fall inside/outside the range of timestamps of that day.
yield(:parameter, timestamp)
yield(:parameter, timestamp + span)
negate = (operator == :ne) ? 'NOT ' : ''
field_sql = field.to_sql(operator, &block)
return "#{negate}(#{field_sql} >= ? AND #{field_sql} < ?)"
elsif operator == :gt
# Make sure timestamps on the given date are not included in the results
# by moving the date to the next day.
timestamp += span
operator = :gte
elsif operator == :lte
# Make sure the timestamps of the given date are included by moving the
# date to the next date.
timestamp += span
operator = :lt
end
end
# Yield the timestamp and return the SQL test
yield(:parameter, timestamp)
"#{field.to_sql(operator, &block)} #{sql_operator(operator, field)} ?"
end
# Validate the key name is in the set and translate the value to the set value.
def set_test(field, operator,value, &block)
set_value = field.complete_value[value.to_sym]
raise ScopedSearch::QueryNotSupported, "'#{field.field}' should be one of '#{field.complete_value.keys.join(', ')}', but the query was '#{value}'" if set_value.nil?
raise ScopedSearch::QueryNotSupported, "Operator '#{operator}' not supported for '#{field.field}'" unless [:eq,:ne].include?(operator)
negate = ''
if [true,false].include?(set_value)
negate = 'NOT ' if operator == :ne
if field.numerical?
operator = (set_value == true) ? :gt : :eq
set_value = 0
else
operator = (set_value == true) ? :ne : :eq
set_value = false
end
end
yield(:parameter, set_value)
return "#{negate}(#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?)"
end
# Generates a simple SQL test expression, for a field and value using an operator.
#
# This function needs a block that can be used to pass other information about the query
# (parameters that should be escaped, includes) to the query builder.
#
# field:: The field to test.
# operator:: The operator used for comparison.
# value:: The value to compare the field with.
def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
return field.to_ext_method_sql(lhs, sql_operator(operator, field), value, &block) if field.ext_method
yield(:keyparameter, lhs.sub(/^.*\./,'')) if field.key_field
if [:like, :unlike].include?(operator)
yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.tr_s('%*', '%'))
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
elsif field.temporal?
return datetime_test(field, operator, value, &block)
elsif field.set?
return set_test(field, operator, value, &block)
else
value = value.to_i if field.offset
yield(:parameter, value)
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
end
end
# This module gets included into the Field class to add SQL generation.
module Field
# Return an SQL representation for this field. Also make sure that
# the relation which includes the search field is included in the
# SQL query.
#
# This function may yield an :include that should be used in the
# ActiveRecord::Base#find call, to make sure that the field is available
# for the SQL query.
def to_sql(operator = nil, &block) # :yields: finder_option_type, value
num = rand(1000000)
connection = klass.connection
if key_relation
yield(:joins, construct_join_sql(key_relation, num) )
yield(:keycondition, "#{key_klass.table_name}_#{num}.#{connection.quote_column_name(key_field.to_s)} = ?")
klass_table_name = relation ? "#{klass.table_name}_#{num}" : connection.quote_table_name(klass.table_name)
return "#{klass_table_name}.#{connection.quote_column_name(field.to_s)}"
elsif key_field
yield(:joins, construct_simple_join_sql(num))
yield(:keycondition, "#{key_klass.table_name}_#{num}.#{connection.quote_column_name(key_field.to_s)} = ?")
klass_table_name = relation ? "#{klass.table_name}_#{num}" : connection.quote_table_name(klass.table_name)
return "#{klass_table_name}.#{connection.quote_column_name(field.to_s)}"
elsif relation
yield(:include, relation)
end
column_name = connection.quote_table_name(klass.table_name.to_s) + "." + connection.quote_column_name(field.to_s)
column_name = "(#{column_name} >> #{offset*word_size} & #{2**word_size - 1})" if offset
column_name
end
# This method construct join statement for a key value table
# It assume the following table structure
# +----------+ +---------+ +--------+
# | main | | value | | key |
# | main_pk | | main_fk | | |
# | | | key_fk | | key_pk |
# +----------+ +---------+ +--------+
# uniq name for the joins are needed in case that there is more than one condition
# on different keys in the same query.
def construct_join_sql(key_relation, num )
join_sql = ""
connection = klass.connection
key = key_relation.to_s.singularize.to_sym
main = definition.klass.to_s.gsub(/.*::/,'').underscore.to_sym
key_table = klass.reflections[key].table_name
key_table_pk = klass.reflections[key].klass.primary_key
value_table = klass.table_name.to_s
value_table_fk_key = klass.reflections[key].association_foreign_key
if klass.reflections[main]
main_table = definition.klass.table_name
main_table_pk = klass.reflections[main].klass.primary_key
value_table_fk_main = klass.reflections[main].association_foreign_key
join_sql = "\n INNER JOIN #{connection.quote_table_name(value_table)} #{value_table}_#{num} ON (#{main_table}.#{main_table_pk} = #{value_table}_#{num}.#{value_table_fk_main})"
value_table = " #{value_table}_#{num}"
end
join_sql += "\n INNER JOIN #{connection.quote_table_name(key_table)} #{key_table}_#{num} ON (#{key_table}_#{num}.#{key_table_pk} = #{value_table}.#{value_table_fk_key}) "
return join_sql
end
# This method construct join statement for a key value table
# It assume the following table structure
# +----------+ +---------+
# | main | | key |
# | main_pk | | value |
# | | | main_fk |
# +----------+ +---------+
# uniq name for the joins are needed in case that there is more than one condition
# on different keys in the same query.
def construct_simple_join_sql( num )
connection = klass.connection
main = definition.klass.to_s.gsub(/.*::/,'').underscore.to_sym
key_value_table = klass.table_name
main_table = definition.klass.table_name
main_table_pk = klass.reflections[main].klass.primary_key
value_table_fk_main = klass.reflections[main].options[:foreign_key]
value_table_fk_main ||= klass.reflections[main].association_foreign_key
join_sql = "\n INNER JOIN #{connection.quote_table_name(key_value_table)} #{key_value_table}_#{num} ON (#{connection.quote_table_name(main_table)}.#{main_table_pk} = #{key_value_table}_#{num}.#{value_table_fk_main})"
return join_sql
end
def to_ext_method_sql(key, operator, value, &block)
raise ScopedSearch::QueryNotSupported, "'#{definition.klass}' doesn't respond to '#{ext_method}'" unless definition.klass.respond_to?(ext_method)
conditions = definition.klass.send(ext_method.to_sym,key, operator, value) rescue {}
raise ScopedSearch::QueryNotSupported, "external method '#{ext_method}' should return hash" unless conditions.kind_of?(Hash)
sql = ''
conditions.map do |notification, content|
case notification
when :include then yield(:include, content)
when :joins then yield(:joins, content)
when :conditions then sql = content
when :parameter then content.map{|c| yield(:parameter, c)}
end
end
return sql
end
end
# This module contains modules for every AST::Node class to add SQL generation.
module AST
# Defines the to_sql method for AST LeadNodes
module LeafNode
def to_sql(builder, definition, &block)
# for boolean fields allow a short format (example: for 'enabled = true' also allow 'enabled')
field = definition.field_by_name(value)
if field && field.set? && field.complete_value.values.include?(true)
key = field.complete_value.map{|k,v| k if v == true}.compact.first
return builder.set_test(field, :eq, key, &block)
end
# Search keywords found without context, just search on all the default fields
fragments = definition.default_fields_for(value).map do |field|
builder.sql_test(field, field.default_operator, value,'', &block)
end
case fragments.length
when 0 then nil
when 1 then fragments.first
else "#{fragments.join(' OR ')}"
end
end
end
# Defines the to_sql method for AST operator nodes
module OperatorNode
# Returns an IS (NOT) NULL SQL fragment
def to_null_sql(builder, definition, &block)
field = definition.field_by_name(rhs.value)
raise ScopedSearch::QueryNotSupported, "Field '#{rhs.value}' not recognized for searching!" unless field
if field.key_field
yield(:parameter, rhs.value.to_s.sub(/^.*\./,''))
end
case operator
when :null then "#{field.to_sql(builder, &block)} IS NULL"
when :notnull then "#{field.to_sql(builder, &block)} IS NOT NULL"
end
end
# No explicit field name given, run the operator on all default fields
def to_default_fields_sql(builder, definition, &block)
raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
# Search keywords found without context, just search on all the default fields
fragments = definition.default_fields_for(rhs.value, operator).map { |field|
builder.sql_test(field, operator, rhs.value,'', &block) }.compact
case fragments.length
when 0 then nil
when 1 then fragments.first
else "#{fragments.join(' OR ')}"
end
end
# Explicit field name given, run the operator on the specified field only
def to_single_field_sql(builder, definition, &block)
raise ScopedSearch::QueryNotSupported, "Field name not a leaf node" unless lhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
raise ScopedSearch::QueryNotSupported, "Value not a leaf node" unless rhs.kind_of?(ScopedSearch::QueryLanguage::AST::LeafNode)
# Search only on the given field.
field = definition.field_by_name(lhs.value)
raise ScopedSearch::QueryNotSupported, "Field '#{lhs.value}' not recognized for searching!" unless field
builder.sql_test(field, operator, rhs.value,lhs.value, &block)
end
# Convert this AST node to an SQL fragment.
def to_sql(builder, definition, &block)
if operator == :not && children.length == 1
builder.to_not_sql(rhs, definition, &block)
elsif [:null, :notnull].include?(operator)
to_null_sql(builder, definition, &block)
elsif children.length == 1
to_default_fields_sql(builder, definition, &block)
elsif children.length == 2
to_single_field_sql(builder, definition, &block)
else
raise ScopedSearch::QueryNotSupported, "Don't know how to handle this operator node: #{operator.inspect} with #{children.inspect}!"
end
end
end
# Defines the to_sql method for AST AND/OR operators
module LogicalOperatorNode
def to_sql(builder, definition, &block)
fragments = children.map { |c| c.to_sql(builder, definition, &block) }.compact.map { |sql| "(#{sql})" }
fragments.empty? ? nil : "#{fragments.join(" #{operator.to_s.upcase} ")}"
end
end
end
# The MysqlAdapter makes sure that case sensitive comparisons are used
# when using the (not) equals operator, regardless of the field's
# collation setting.
class MysqlAdapter < ScopedSearch::QueryBuilder
# Patches the default sql_operator method to add
# BINARY after the equals and not equals operator to force
# case-sensitive comparisons.
def sql_operator(operator, field)
if [:ne, :eq].include?(operator) && field.textual?
"#{SQL_OPERATORS[operator]} BINARY"
else
super(operator, field)
end
end
end
class Mysql2Adapter < ScopedSearch::QueryBuilder
# Patches the default sql_operator method to add
# BINARY after the equals and not equals operator to force
# case-sensitive comparisons.
def sql_operator(operator, field)
if [:ne, :eq].include?(operator) && field.textual?
"#{SQL_OPERATORS[operator]} BINARY"
else
super(operator, field)
end
end
end
# The PostgreSQLAdapter make sure that searches are case sensitive when
# using the like/unlike operators, by using the PostrgeSQL-specific
# ILIKE operator instead of LIKE.
class PostgreSQLAdapter < ScopedSearch::QueryBuilder
# Switches out the default LIKE operator for ILIKE in the default
# sql_operator method.
def sql_operator(operator, field)
raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if [:like, :unlike].include?(operator) and !field.textual?
case operator
when :like then 'ILIKE'
when :unlike then 'NOT ILIKE'
else super(operator, field)
end
end
# Returns a NOT (...) SQL fragment that negates the current AST node's children
def to_not_sql(rhs, definition, &block)
"NOT COALESCE(#{rhs.to_sql(self, definition, &block)}, false)"
end
end
# The Oracle adapter also requires some tweaks to make the case insensitive LIKE work.
class OracleEnhancedAdapter < ScopedSearch::QueryBuilder
def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
if field.key_field
yield(:parameter, lhs.sub(/^.*\./,''))
end
if field.textual? && [:like, :unlike].include?(operator)
yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.to_s.tr_s('%*', '%'))
return "LOWER(#{field.to_sql(operator, &block)}) #{self.sql_operator(operator, field)} LOWER(?)"
elsif field.temporal?
return datetime_test(field, operator, value, &block)
else
yield(:parameter, value)
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
end
end
end
end
# Include the modules into the corresponding classes
# to add SQL generation capabilities to them.
Definition::Field.send(:include, QueryBuilder::Field)
QueryLanguage::AST::LeafNode.send(:include, QueryBuilder::AST::LeafNode)
QueryLanguage::AST::OperatorNode.send(:include, QueryBuilder::AST::OperatorNode)
QueryLanguage::AST::LogicalOperatorNode.send(:include, QueryBuilder::AST::LogicalOperatorNode)
end