Skip to content

Commit

Permalink
* Refactored association code to be much simpler and rely on recursio…
Browse files Browse the repository at this point in the history
…n. This allows the underlying class to do most of the work. This also allows calling any named scopes through any level of associations.
  • Loading branch information
binarylogic committed Jul 31, 2009
1 parent 8a21abe commit 74d5631
Show file tree
Hide file tree
Showing 17 changed files with 191 additions and 210 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rdoc
@@ -1,3 +1,7 @@
== 2.2.0 released 2009-07-30

* Refactored association code to be much simpler and rely on recursion. This allows the underlying class to do most of the work. This also allows calling any named scopes through any level of associations.

== 2.1.13 released 2009-07-29

* Applied bug fix from http://github.com/skanev/searchlogic to make #order work with association ordering.
Expand Down
8 changes: 4 additions & 4 deletions README.rdoc
Expand Up @@ -83,7 +83,7 @@ You also get named scopes for any of your associations:
User.ascend_by_order_total
User.descend_by_orders_line_items_price

Again these are just named scopes. You can chain them together, call methods off of them, etc. What's great about these named scopes is that they do NOT use the :include option, making them <em>much</em> faster. Instead they create a INNER JOIN and pass it to the :joins option, which is great for performance. To prove my point here is a quick benchmark from an application I am working on:
Again these are just named scopes. You can chain them together, call methods off of them, etc. What's great about these named scopes is that they do NOT use the :include option, making them <em>much</em> faster. Instead they leverage the :joins option, which is great for performance. To prove my point here is a quick benchmark from an application I am working on:

Benchmark.bm do |x|
x.report { 10.times { Event.tickets_id_gt(10).all(:include => :tickets) } }
Expand All @@ -97,7 +97,7 @@ If you want to use the :include option, just specify it:

User.orders_line_items_price_greater_than(20).all(:include => {:orders => :line_items})

Obviously, only do this if you want to actually use the included objects.
Obviously, only do this if you want to actually use the included objects. Including objects into a query can be helpful with performance, especially when solving an N+1 query problem.

== Make searching and ordering data in your application trivial

Expand Down Expand Up @@ -176,7 +176,7 @@ Now just throw it in your form:
= f.check_box :four_year_olds
= f.submit

What's great about this is that you can do just about anything you want. If Searchlogic doesn't provide a named scope for that crazy edge case that you need, just create your own named scope. The sky is the limit.
This really allows Searchlogic to extend beyond what it provides internally. If Searchlogic doesn't provide a named scope for that crazy edge case that you need, just create your own named scope and use it. The sky is the limit.

== Use any or all

Expand All @@ -199,7 +199,7 @@ If you don't like will_paginate, use another solution, or roll your own. Paginat

== Conflicts with other gems

You will notice searchlogic wants to create a method called "search". So do other libraries like thinking sphinx, etc. So searchlogic has a no conflict resolution. If the "search" method is already taken the method will be called "searchlogic" instead. So instead of
You will notice searchlogic wants to create a method called "search". So do other libraries like thinking-sphinx, etc. So searchlogic has a no conflict resolution. If the "search" method is already taken the method will be called "searchlogic" instead. So instead of

User.search

Expand Down
16 changes: 14 additions & 2 deletions lib/searchlogic.rb
@@ -1,6 +1,7 @@
require "searchlogic/core_ext/proc"
require "searchlogic/core_ext/object"
require "searchlogic/active_record_consistency"
require "searchlogic/active_record/consistency"
require "searchlogic/active_record/named_scopes"
require "searchlogic/named_scopes/conditions"
require "searchlogic/named_scopes/ordering"
require "searchlogic/named_scopes/association_conditions"
Expand All @@ -10,10 +11,21 @@

Proc.send(:include, Searchlogic::CoreExt::Proc)
Object.send(:include, Searchlogic::CoreExt::Object)

module ActiveRecord # :nodoc: all
class Base
class << self
include Searchlogic::ActiveRecord::Consistency
end
end
end

ActiveRecord::Base.extend(Searchlogic::ActiveRecord::NamedScopes)

ActiveRecord::Base.extend(Searchlogic::NamedScopes::Conditions)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::Ordering)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::AssociationConditions)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::AssociationOrdering)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::Ordering)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::AliasScope)
ActiveRecord::Base.extend(Searchlogic::Search::Implementation)

Expand Down
22 changes: 22 additions & 0 deletions lib/searchlogic/active_record/consistency.rb
@@ -0,0 +1,22 @@
module Searchlogic
module ActiveRecord
# Active Record is pretty inconsistent with how their SQL is constructed. This
# method attempts to close the gap between the various inconsistencies.
module Consistency
def self.included(klass)
klass.class_eval do
alias_method_chain :merge_joins, :searchlogic
end
end

# In AR multiple joins are sometimes in a single join query, and other times they
# are not. The merge_joins method in AR should account for this, but it doesn't.
# This fixes that problem. This way there is one join per string, which allows
# the merge_joins method to delete duplicates.
def merge_joins_with_searchlogic(*args)
joins = merge_joins_without_searchlogic(*args)
joins.collect { |j| j.is_a?(String) ? j.split(" ") : j }.flatten.uniq
end
end
end
end
51 changes: 51 additions & 0 deletions lib/searchlogic/active_record/named_scopes.rb
@@ -0,0 +1,51 @@
module Searchlogic
module ActiveRecord
# Adds methods that give extra information about a classes named scopes.
module NamedScopes
# Retrieves the options passed when creating the respective named scope. Ex:
#
# named_scope :whatever, :conditions => {:column => value}
#
# This method will return:
#
# :conditions => {:column => value}
#
# ActiveRecord hides this internally in a Proc, so we have to try and pull it out with this
# method.
def named_scope_options(name)
key = scopes.key?(name.to_sym) ? name.to_sym : primary_condition_name(name)

if key
eval("options", scopes[key].binding)
else
nil
end
end

# The arity for a named scope's proc is important, because we use the arity
# to determine if the condition should be ignored when calling the search method.
# If the condition is false and the arity is 0, then we skip it all together. Ex:
#
# User.named_scope :age_is_4, :conditions => {:age => 4}
# User.search(:age_is_4 => false) == User.all
# User.search(:age_is_4 => true) == User.all(:conditions => {:age => 4})
#
# We also use it when trying to "copy" the underlying named scope for association
# conditions. This way our aliased scope accepts the same number of parameters for
# the underlying scope.
def named_scope_arity(name)
options = named_scope_options(name)
options.respond_to?(:arity) ? options.arity : nil
end

# A convenience method for creating inner join sql to that your inner joins
# are consistent with how Active Record creates them. Basically a tool for
# you to use when writing your own named scopes. This way you know for sure
# that duplicate joins will be removed when chaining scopes together that
# use the same join.
def inner_joins(association_name)
ActiveRecord::Associations::ClassMethods::InnerJoinDependency.new(self, association_name, nil).join_associations.collect { |assoc| assoc.association_join }
end
end
end
end
28 changes: 0 additions & 28 deletions lib/searchlogic/active_record_consistency.rb

This file was deleted.

1 change: 1 addition & 0 deletions lib/searchlogic/named_scopes/alias_scope.rb
Expand Up @@ -48,6 +48,7 @@ def alias_scopes # :nodoc:
end

def alias_scope?(name) # :nodoc:
return false if name.blank?
alias_scopes.key?(name.to_sym)
end

Expand Down
86 changes: 19 additions & 67 deletions lib/searchlogic/named_scopes/association_conditions.rb
Expand Up @@ -3,100 +3,52 @@ module NamedScopes
# Handles dynamically creating named scopes for associations.
module AssociationConditions
def condition?(name) # :nodoc:
super || association_condition?(name) || association_alias_condition?(name)
super || association_condition?(name)
end

def primary_condition_name(name) # :nodoc:
if result = super
result
elsif association_condition?(name)
name.to_sym
elsif details = association_alias_condition_details(name)
"#{details[:association]}_#{details[:column]}_#{primary_condition(details[:condition])}".to_sym
else
nil
end
end

# Is the name of the method a valid name for an association condition?
def association_condition?(name)
!association_condition_details(name).nil?
end

# Is the named of the method a valid name for an association alias condition?
# An alias being "gt" for "greater_than", etc.
def association_alias_condition?(name)
!association_alias_condition_details(name).nil?
end

# A convenience method for creating inner join sql to that your inner joins
# are consistent with how Active Record creates them. Basically a tool for
# you to use when writing your own named scopes. This way you know for sure
# that duplicate joins will be removed when chaining scopes together that
# use the same join.
def inner_joins(association_name)
ActiveRecord::Associations::ClassMethods::InnerJoinDependency.new(self, association_name, nil).join_associations.collect { |assoc| assoc.association_join }
end

private
def association_condition?(name)
!association_condition_details(name).nil?
end

def method_missing(name, *args, &block)
if details = association_condition_details(name)
create_association_condition(details[:association], details[:column], details[:condition], args)
send(name, *args)
elsif details = association_alias_condition_details(name)
create_association_alias_condition(details[:association], details[:column], details[:condition], args)
create_association_condition(details[:association], details[:condition], args)
send(name, *args)
else
super
end
end

def association_condition_details(name)
assocs = non_polymorphic_associations
assocs = reflect_on_all_associations.reject { |assoc| assoc.options[:polymorphic] }
return nil if assocs.empty?
regexes = [association_searchlogic_regex(assocs, Conditions::PRIMARY_CONDITIONS)]
assocs.each do |assoc|
scope_names = assoc.klass.scopes.keys + assoc.klass.alias_scopes.keys
scope_names.uniq!
scope_names.delete(:scoped)
next if scope_names.empty?
regexes << /^(#{assoc.name})_(#{scope_names.join("|")})$/
end

if !local_condition?(name) && regexes.any? { |regex| name.to_s =~ regex }
{:association => $1, :column => $2, :condition => $3}
end
end

def create_association_condition(association_name, column, condition, args)
name_parts = [column, condition].compact
condition_name = name_parts.join("_")
named_scope("#{association_name}_#{condition_name}", association_condition_options(association_name, condition_name, args))
end

def association_alias_condition_details(name)
assocs = non_polymorphic_associations
return nil if assocs.empty?

if !local_condition?(name) && name.to_s =~ association_searchlogic_regex(assocs, Conditions::ALIAS_CONDITIONS)
{:association => $1, :column => $2, :condition => $3}
if name.to_s =~ /^(#{assocs.collect(&:name).join("|")})_(\w+)$/
association_name = $1
condition = $2
association = reflect_on_association(association_name.to_sym)
klass = association.klass
if klass.condition?(condition)
{:association => $1, :condition => $2}
else
nil
end
end
end

def non_polymorphic_associations
reflect_on_all_associations.reject { |assoc| assoc.options[:polymorphic] }
end

def association_searchlogic_regex(assocs, condition_names)
/^(#{assocs.collect(&:name).join("|")})_(\w+)_(#{condition_names.join("|")})$/
end

def create_association_alias_condition(association, column, condition, args)
primary_condition = primary_condition(condition)
alias_name = "#{association}_#{column}_#{condition}"
primary_name = "#{association}_#{column}_#{primary_condition}"
send(primary_name, *args) # go back to method_missing and make sure we create the method
(class << self; self; end).class_eval { alias_method alias_name, primary_name }
def create_association_condition(association, condition, args)
named_scope("#{association}_#{condition}", association_condition_options(association, condition, args))
end

def association_condition_options(association_name, association_condition, args)
Expand Down
28 changes: 21 additions & 7 deletions lib/searchlogic/named_scopes/association_ordering.rb
Expand Up @@ -2,14 +2,28 @@ module Searchlogic
module NamedScopes
# Handles dynamically creating named scopes for associations.
module AssociationOrdering
def association_ordering_condition?(name)
!association_ordering_condition_details(name).nil?
def condition?(name) # :nodoc:
super || association_ordering_condition?(name)
end

def primary_condition_name(name) # :nodoc
if result = super
result
elsif association_ordering_condition?(name)
name.to_sym
else
nil
end
end

private
def association_ordering_condition?(name)
!association_ordering_condition_details(name).nil?
end

def method_missing(name, *args, &block)
if details = association_ordering_condition_details(name)
create_association_ordering_condition(details[:association], details[:order_as], details[:column], args)
create_association_ordering_condition(details[:association], details[:order_as], details[:condition], args)
send(name, *args)
else
super
Expand All @@ -18,13 +32,13 @@ def method_missing(name, *args, &block)

def association_ordering_condition_details(name)
associations = reflect_on_all_associations.collect { |assoc| assoc.name }
if !local_condition?(name) && name.to_s =~ /^(ascend|descend)_by_(#{associations.join("|")})_(\w+)$/
{:order_as => $1, :association => $2, :column => $3}
if name.to_s =~ /^(ascend|descend)_by_(#{associations.join("|")})_(\w+)$/
{:order_as => $1, :association => $2, :condition => $3}
end
end

def create_association_ordering_condition(association_name, order_as, column, args)
named_scope("#{order_as}_by_#{association_name}_#{column}", association_condition_options(association_name, "#{order_as}_by_#{column}", args))
def create_association_ordering_condition(association, order_as, condition, args)
named_scope("#{order_as}_by_#{association}_#{condition}", association_condition_options(association, "#{order_as}_by_#{condition}", args))
end
end
end
Expand Down

0 comments on commit 74d5631

Please sign in to comment.