Skip to content
Find file
Fetching contributors…
Cannot retrieve contributors at this time
executable file 520 lines (460 sloc) 19.7 KB
require 'paginator'
require 'extensions'
# Squirrel is a library for making querying the database using ActiveRecord cleaner, easier
# to read, and less prone to user error. It does this by allowing AR::Base#find to take a block,
# which is run to build the conditions and includes required to execute the query.
module Squirrel
# When included in AR::Base, it chains the #find method to allow for block execution.
module Hook
def find_with_squirrel *args, &blk
args ||= [:all]
if blk || (args.last.is_a?(Hash) && args.last.has_key?(:paginate))
query = Query.new(self, &blk)
query.execute(*args)
else
find_without_squirrel(*args)
end
end
def scoped_with_squirrel *args, &blk
if blk
query = Query.new(self, &blk)
self.scoped(query.to_find_parameters)
else
scoped_without_squirrel(*args)
end
end
def self.included base
if ! base.instance_methods.include?('find_without_squirrel') &&
base.instance_methods.include?('find')
base.class_eval do
alias_method :find_without_squirrel, :find
alias_method :find, :find_with_squirrel
end
end
if ! base.instance_methods.include?('scoped_without_squirrel') &&
base.instance_methods.include?('scoped')
base.class_eval do
alias_method :scoped_without_squirrel, :scoped
alias_method :scoped, :scoped_with_squirrel
end
end
end
end
module NamedScopeHook
def scoped *args, &blk
args = blk ? [Query.new(self, &blk).to_find_parameters] : args
scopes[:scoped].call(self, *args)
end
end
# The Query is what contains the query and is what handles execution and pagination of the
# result set.
class Query
attr_reader :conditions, :model
# Creates a Query specific to the given model (which is a class that descends from AR::Base)
# and a block that will be run to find the conditions for the #find call.
def initialize model, &blk
@model = model
@joins = nil
@binding = blk && blk.binding
@conditions = ConditionGroup.new(@model, "AND", @binding, &blk)
@conditions.assign_joins( join_dependency )
end
# Builds the dependencies needed to find what AR plans to call the tables in the query
# by finding and sending what would be passed in as the +include+ parameter to #find.
# This is a necessary step because what AR calls tables deeply nested might not be
# completely obvious.)
def join_dependency
jd = ::ActiveRecord::Associations::ClassMethods::JoinDependency
@join_dependency ||= jd.new model,
@conditions.to_find_include,
nil
end
# Runs the block which builds the conditions. If requested, paginates the result_set.
# If the first parameter to #find is :query (instead of :all or :first), then the query
# object itself is returned. Useful for debugging, but not much else.
def execute *args
if args.first == :query
self
else
opts = args.last.is_a?(Hash) ? args.last : {}
results = []
pagination = opts.delete(:paginate) || {}
model.send(:with_scope, :find => opts) do
@conditions.paginate(pagination) unless pagination.empty?
results = model.find args[0], to_find_parameters
if @conditions.paginate?
paginate_result_set results, to_find_parameters
end
end
results
end
end
def to_find_parameters
find_parameters = {}
find_parameters[:conditions] = to_find_conditions unless to_find_conditions.blank?
find_parameters[:include ] = to_find_include unless to_find_include.blank?
find_parameters[:order ] = to_find_order unless to_find_order.blank?
find_parameters[:limit ] = to_find_limit unless to_find_limit.blank?
find_parameters[:offset ] = to_find_offset unless to_find_offset.blank?
find_parameters
end
# Delegates the to_find_conditions call to the root ConditionGroup
def to_find_conditions
@conditions.to_find_conditions
end
# Delegates the to_find_include call to the root ConditionGroup
def to_find_include
@conditions.to_find_include
end
# Delegates the to_find_order call to the root ConditionGroup
def to_find_order
@conditions.to_find_order
end
# Delegates the to_find_limit call to the root ConditionGroup
def to_find_limit
@conditions.to_find_limit
end
# Delegates the to_find_offset call to the root ConditionGroup
def to_find_offset
@conditions.to_find_offset
end
# Used by #execute to paginates the result set if
# pagination was requested. In this case, it adds +pages+ and +total_results+ accessors
# to the result set. See Paginator for more details.
def paginate_result_set set, conditions
limit = conditions.delete(:limit)
offset = conditions.delete(:offset)
class << set
attr_reader :pages
attr_reader :total_results
end
total_results = model.count(conditions)
set.instance_variable_set("@pages",
Paginator.new( :count => total_results,
:limit => limit,
:offset => offset) )
set.instance_variable_set("@total_results", total_results)
set.extend( Squirrel::WillPagination )
end
# ConditionGroups are groups of Conditions, oddly enough. They most closely map to models
# in your schema, but they also handle the grouping jobs for the #any and #all blocks.
class ConditionGroup
attr_accessor :model, :logical_join, :binding, :reflection, :path
# Creates a ConditionGroup by passing in the following arguments:
# * model: The AR subclass that defines what columns and associations will be accessible
# in the given block.
# * logical_join: A string containing the join that will be used when concatenating the
# conditions together. The root level ConditionGroup created by Query defaults the
# join to be "AND", but the #any and #all methods will create specific ConditionGroups
# using "OR" and "AND" as their join, respectively.
# * binding: The +binding+ of the block passed to the original #find. Will be used to
# +eval+ what +self+ would be. This is necessary for using methods like +params+ and
# +session+ in your controllers.
# * path: The "path" taken through the models to arrive at this model. For example, if
# your User class has_many Posts which has_many Comments each of which belongs_to User,
# the path to the second User would be [:posts, :comments, :user]
# * reflection: The association used to get to this block. If nil, then no new association
# was traversed, which means we're in an #any or #all grouping block.
# * blk: The block to be executed.
#
# This method defines a number of methods to be available inside the block, one for each
# of the columns and associations in the specified model. Note that you CANNOT use
# user-defined methods on your model inside Squirrel queries. They don't have any meaning
# in the context of a database query.
def initialize model, logical_join, binding, path = nil, reflection = nil, &blk
@model = model
@logical_join = logical_join
@conditions = []
@condition_blocks = []
@reflection = reflection
@path = [ path, reflection ].compact.flatten
@binding = binding
@order = []
@negative = false
@paginator = false
@block = blk
existing_methods = self.class.instance_methods(false)
(model.column_names - existing_methods).each do |col|
(class << self; self; end).class_eval do
define_method(col.to_s.intern) do
column(col)
end
end
end
(model.reflections.keys - existing_methods).each do |assn|
(class << self; self; end).class_eval do
define_method(assn.to_s.intern) do
association(assn)
end
end
end
execute_block
end
# Creates a Condition and queues it for inclusion. When calling a method defined
# during the creation of the ConditionGroup object is the same as calling column(:column_name).
# This is useful if you need to access a column that happens to coincide with the name of
# an already-defined method (e.g. anything returned by instance_methods(false) for the
# given model).
def column name
@conditions << Condition.new(name)
@conditions.last
end
# Similar to #column, this will create an association even if you can't use the normal
# method version.
def association name, &blk
name = name.to_s.intern
ref = @model.reflect_on_association(name)
@condition_blocks << ConditionGroup.new(ref.klass, logical_join, binding, path, ref.name, &blk)
@condition_blocks.last
end
# Creates a ConditionGroup that has the logical_join set to "OR".
def any &blk
@condition_blocks << ConditionGroup.new(model, "OR", binding, path, &blk)
@condition_blocks.last
end
# Creates a ConditionGroup that has the logical_join set to "AND".
def all &blk
@condition_blocks << ConditionGroup.new(model, "AND", binding, path, &blk)
@condition_blocks.last
end
# Sets the arguments for the :order parameter. Arguments can be columns (i.e. Conditions)
# or they can be strings (for "RANDOM()", etc.). If a Condition is used, and the column is
# negated using #not or #desc, then the resulting specification in the ORDER clause will
# be ordered descending. That is, "order_by name.desc" will become "ORDER name DESC"
def order_by *columns
@order += [columns].flatten
end
# Flags the result set to be paginated according to the :page and :per_page parameters
# to this method.
def paginate opts = {}
@paginator = true
page = (opts[:page] || 1).to_i
per_page = (opts[:per_page] || 20).to_i
page = 1 if page < 1
limit( per_page, ( page - 1 ) * per_page )
end
# Similar to #paginate, but does not flag the result set for pagination. Takes a limit
# and an offset (by default the offset is 0).
def limit lim, off = nil
@limit = ( lim || @limit ).to_i
@offset = ( off || @offset ).to_i
end
# Returns true if this ConditionGroup or any of its subgroups have been flagged for pagination.
def paginate?
@paginator || @condition_blocks.any?(&:paginate?)
end
# Negates the condition. Essentially prefixes the condition with NOT in the final query.
def -@
@negative = !@negative
self
end
alias_method :desc, :-@
# Negates the condition. Also works to negate ConditionGroup blocks in a more straightforward
# manner, like so:
# any.not do
# id == 1
# name == "Joe"
# end
#
# # => "NOT( id = 1 OR name = 'Joe')"
def not &blk
@negative = !@negative
if blk
@block = blk
execute_block
end
end
# Takes the JoinDependency object and filters it down through the ConditionGroups
# to make sure each one knows the aliases necessary to refer to each table by its
# correct name.
def assign_joins join_dependency, ancestries = nil
ancestries ||= join_dependency.join_associations.map{|ja| ja.ancestry }
unless @conditions.empty?
my_association = unless @path.blank?
join_dependency.join_associations[ancestries.index(@path)]
else
join_dependency.join_base
end
@conditions.each do |column|
column.assign_join(my_association)
end
end
@condition_blocks.each do |association|
association.assign_joins(join_dependency, ancestries)
end
end
# Generates the parameter for :include for this ConditionGroup and all its subgroups.
def to_find_include
@condition_blocks.inject({}) do |inc, cb|
if cb.reflection.nil?
inc.merge_tree(cb.to_find_include)
else
inc[cb.reflection] ||= {}
inc[cb.reflection] = inc[cb.reflection].merge_tree(cb.to_find_include)
inc
end
end
end
# Generates the :order parameter for this ConditionGroup. Because this does not reference
# subgroups it should only be used from the outermost block (which is probably where it makes
# the most sense to reference it, but it's worth mentioning)
def to_find_order
if @order.blank?
nil
else
@order.collect do |col|
col.respond_to?(:full_name) ? (col.full_name + (col.negative? ? " DESC" : "")) : col
end.join(", ")
end
end
# Generates the :conditions parameter for this ConditionGroup and all subgroups. It
# generates them in ["sql", params] format because of the requirements of LIKE, etc.
def to_find_conditions
segments = conditions.collect{|c| c.to_find_conditions }.compact
return nil if segments.length == 0
cond = "(" + segments.collect{|s| s.first }.join(" #{logical_join} ") + ")"
cond = "NOT #{cond}" if negative?
values = segments.inject([]){|all, now| all + now[1..-1] }
[ cond, *values ]
end
# Generates the :limit parameter.
def to_find_limit
@limit
end
# Generates the :offset parameter.
def to_find_offset
@offset
end
# Returns all the conditions, which is the union of the Conditions and ConditionGroups
# that belong to this ConditionGroup.
def conditions
@conditions + @condition_blocks
end
# Returns true if this block has been negated using #not, #desc, or #-
def negative?
@negative
end
# This is a bit of a hack, due to how Squirrel is built. It can be used to fetch
# instance variables from the location where the call to #find was made. For example,
# if called from within your model and you happened to have an instance variable called
# "@foo", you can access it by calling
# instance "@foo"
# from within your Squirrel query.
def instance instance_var
s = eval("self", binding)
if s
s.instance_variable_get(instance_var)
end
end
private
def execute_block #:nodoc:
instance_eval &@block if @block
end
def method_missing meth, *args #:nodoc:
m = eval <<-end_eval, binding
begin
method(:#{meth})
rescue NameError
nil
end
end_eval
if m
m.call(*args)
else
super(meth, *args)
end
end
end
# Handles comparisons in the query. This class is analagous to the columns in the database.
# When comparing the Condition to a value, the operators are used as follows:
# * ==, === : Straight-up Equals. Can also be used as the "IN" operator if the operand is an Array.
# Additionally, when the oprand is +nil+, the comparison is correctly generates as "IS NULL"."
# * =~ : The LIKE and REGEXP operators. If the operand is a String, it will generate a LIKE
# comparison. If it is a Regexp, the REGEXP operator will be used. NOTE: MySQL regular expressions
# are NOT the same as Ruby regular expressions. Also NOTE: No wildcards are inserted into the LIKE
# comparison, so you may add them where you wish.
# * <=> : Performs a BETWEEN comparison, as long as the operand responds to both #first and #last,
# which both Ranges and Arrays do.
# * > : A simple greater-than comparison.
# * >= : Greater-than or equal-to.
# * < : A simple less-than comparison.
# * <= : Less-than or equal-to.
# * contains? : Like =~, except automatically surrounds the operand in %s, which =~ does not do.
# * nil? : Works exactly like "column == nil", but in a nicer syntax, which is what Squirrel is all about.
class Condition
attr_reader :name, :operator, :operand
# Creates and Condition with the given name.
def initialize name
@name = name
@sql = nil
@negative = false
end
[ :==, :===, :=~, :<=>, :<=, :<, :>, :>= ].each do |op|
define_method(op) do |val|
@operator = op
@operand = val
self
end
end
def contains? val #:nodoc:
@operator = :contains
@operand = val
self
end
def nil? #:nodoc:
@operator = :==
@operand = nil
self
end
def -@ #:nodoc:
@negative = !@negative
self
end
alias_method :not, :-@
alias_method :desc, :-@
# Returns true if this Condition has been negated, which means it will be prefixed with "NOT"
def negative?
@negative
end
# Gets the name of the table that this Condition refers to by taking it out of the
# association object.
def assign_join association = nil
@table_alias = association ? "#{association.aliased_table_name}." : ""
end
# Returns the full name of the column, including any assigned table alias.
def full_name
"#{@table_alias}#{name}"
end
# Generates the :condition parameter for this Condition, in ["sql", args] format.]
def to_find_conditions(join_association = {})
return nil if operator.nil?
op, arg_format, values = operator, "?", [operand]
op, arg_format, values = case operator
when :<=> then [ "BETWEEN", "? AND ?", [ operand.first, operand.last ] ]
when :=~ then
case operand
when String then [ "LIKE", arg_format, values ]
when Regexp then [ "REGEXP", arg_format, values.map(&:source) ]
end
when :==, :=== then
case operand
when Array then [ "IN", "(?)", values ]
when Range then [ "IN", "(?)", values ]
when Condition then [ "=", operand.full_name, [] ]
when nil then [ "IS", "NULL", [] ]
else [ "=", arg_format, values ]
end
when :contains then [ "LIKE", arg_format, values.map{|v| "%#{v}%" } ]
else
case operand
when Condition then [ op, oprand.full_name, [] ]
else [ op, arg_format, values ]
end
end
sql = "#{full_name} #{op} #{arg_format}"
sql = "NOT (#{sql})" if @negative
[ sql, *values ]
end
end
end
end
Something went wrong with that request. Please try again.