public
Description: brendan's ENhancements To Rails
Homepage:
Clone URL: git://github.com/brendan/entrails.git
entrails / lib / entrails / active_record / find_by_association.rb
100644 260 lines (209 sloc) 13.429 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
module Entrails::ActiveRecord::FindByAssociation
  
  protected
  
  # Sanitizes a hash of association/value pairs into SQL conditions,
  # passing on attributes that are not associations back down into
  # the original sanitize_sql_hash method.
  def sanitize_sql_hash_with_find_by_association(attrs)
    attrs = attrs.dup
    association_conditions = attrs.map do |attr, value|
      # Lets process Attributes that are names of Associations
      if association = reflect_on_association(attr.to_sym)
        attrs.delete attr
        if association.options[:polymorphic]
          foreign_type_attribute = (association.options[:foreign_type]||"#{association.name}_type").to_s
          polymorphic_type = attrs[foreign_type_attribute.to_sym]
          raise "Polymorphic belongs_to associations must be qualified by the inclusion of a reference to the foreign_type attribute in the conditions hash. Missing #{foreign_type_attribute} key or value in conditions hash passed to sanitize_sql_hash_with_find_by_association for association #{association.name}." unless polymorphic_type
          association_class = polymorphic_type.constantize
        else
          association_class = association.klass
        end
        construct_find_by_association_conditions_sql(association, value, :type => association_class)
      end
    end.compact.join(' AND ')
    non_association_conditions = attrs.empty? ? '' : sanitize_sql_hash_without_find_by_association(attrs)
  
    # Non Association Conditions are cheaper to calculate so we optimize the query plan by issuing them first
    [ non_association_conditions, association_conditions ].reject{|conditions| conditions.blank?}.join(' AND ')
  end
 
  # Prevent infinite recursion when assigning scopes via with_scope block
  # by sanitizing non-string scope conditions.
  def with_scope_with_find_by_association(options={}, action = :merge, &block)
    if options[:find] and options[:find][:conditions] and !options[:find][:conditions].is_a?(String)
      options = options.dup
      options[:find] = options[:find].dup
      options[:find][:conditions] = sanitize_sql(options[:find][:conditions])
    end
    with_scope_without_find_by_association options, action, &block
  end
 
  private
  
  def construct_find_by_association_conditions_sql(association, conditions, options={})
    
    association_type = options[:type]
    association = reflect_on_association(association) unless association.is_a?(ActiveRecord::Reflection::AssociationReflection)
 
    association.options[:polymorphic] and !association_type and
      raise "FindByAssociation requires a :type option for generation of polymorphic belongs_to association subqueries."
    
    association_type ||= association.is_a?(String) ? association_type.constantize : association.klass
 
    # If a nil is present in the association conditions we have to handle as a special case due to the way
    # sql handles NULL values in sets vs. empty sets.
    nil_condition =
      case conditions
      when Array : !!conditions.compact! # compacts the array and returns true/false if it changed
      when NilClass : true
      else false
      end
 
    segments = []
 
    # To handle has_many :through, lets process the through_reflection and then send over the conditions...
    conditions.blank? or segments <<
      if association.options[:through]
        through_association = reflect_on_association(association.through_reflection.name)
        source_association ||= through_association.klass.reflect_on_association(association.options[:source])
        source_association ||= through_association.klass.reflect_on_association(association.name)
        source_association ||= through_association.klass.reflect_on_association(association.name.to_s.singularize.to_sym)
 
        raise "Unknown source_association for HasManyThroughAssociation #{self}##{association.name}." unless source_association
 
        source_subquery_sql = through_association.klass.__send__(:construct_find_by_association_conditions_subquery_sql,
          source_association, conditions, :type => association_type)
 
        through_subquery_sql = construct_find_by_association_conditions_subquery_sql(
          through_association, source_subquery_sql, :type => through_association.klass)
      else
        construct_find_by_association_conditions_subquery_sql(association, conditions, :type => association_type)
      end
 
    nil_condition and segments << construct_find_by_association_conditions_nil_condition_sql(association)
 
    segments.join(' OR ') unless segments.empty?
  
  end
  
  def construct_find_by_association_conditions_nil_condition_sql(association)
    nil_subquery_sql =
      if association.options[:through]
        through_association = reflect_on_association(association.through_reflection.name)
        through_association.klass.__send__(:construct_finder_sql, options_for_find_by_association_conditions_subquery(through_association, nil))
      else
        association.klass.__send__(:construct_finder_sql, options_for_find_by_association_conditions_subquery(association, nil, :type => association.klass))
      end
 
    nil_subquery_sql = "SELECT _id FROM (#{nil_subquery_sql}) _tmp" if use_derived_table_hack_for_subquery_optimization?
 
    case association.macro
    when :belongs_to : "#{table_name}.#{connection.quote_column_name(association.primary_key_name)} NOT IN " <<
                       "(#{nil_subquery_sql}) OR #{table_name}.#{connection.quote_column_name(association.primary_key_name)} IS NULL"
    when :has_one : "#{table_name}.#{connection.quote_column_name(primary_key)} NOT IN (#{nil_subquery_sql})"
    when :has_many : "#{table_name}.#{connection.quote_column_name(primary_key)} NOT IN (#{nil_subquery_sql})"
    when :has_and_belongs_to_many : "#{table_name}.#{connection.quote_column_name(primary_key)} NOT IN " <<
                                    "(SELECT #{connection.quote_column_name(association.primary_key_name)} " <<
                                    "FROM #{association.options[:join_table]} WHERE " <<
                                    "#{connection.quote_column_name(association.association_foreign_key)} IN " <<
                                    "(#{nil_subquery_sql}))"
    else raise "FindByAssociation does not recognize the '#{association.macro}' association macro."
    end
    
  end
 
  def construct_find_by_association_conditions_subquery_sql(association, conditions, options={})
 
    association_type, through_type = options[:type], options[:through_type]
    association = reflect_on_association(association) unless association.is_a?(ActiveRecord::Reflection::AssociationReflection)
    
    association.options[:polymorphic] and !association_type and
      raise "FindByAssociation requires a :type option for generation of polymorphic belongs_to association subqueries."
    
    association_type ||= association_type.is_a?(String) ? association_type.constantize : association.klass
 
    conditions =
      case
      when (conditions == []) : nil
      when (conditions.class.is_a?(Entrails::ActiveRecord::FindByAssociation)) : [conditions]
      else conditions
      end
      
    if conditions.is_a?(Array) and conditions.all?{|c|c.class.is_a?(Entrails::ActiveRecord::FindByAssociation)}
      ids = conditions.map{|c| c.attributes[association_type.primary_key] }
      ids = ids.first unless ids.size > 1
      conditions = { association_type.primary_key => ids }
    end
 
    if conditions and subquery_sql = association_type.__send__(:construct_finder_sql,
      options_for_find_by_association_conditions_subquery(association, conditions, :type => association_type, :through_type => through_type))
 
      subquery_sql &&= "SELECT _id FROM (#{subquery_sql}) _tmp" if use_derived_table_hack_for_subquery_optimization?
 
      case association.macro
      when :belongs_to : "#{table_name}.#{connection.quote_column_name(association.primary_key_name)} IN (#{subquery_sql})"
      when :has_and_belongs_to_many : "#{table_name}.#{connection.quote_column_name(primary_key)} IN " <<
                                      "(SELECT #{connection.quote_column_name(association.primary_key_name)} " <<
                                      "FROM #{association.options[:join_table]} WHERE " <<
                                      "#{connection.quote_column_name(association.association_foreign_key)} IN " <<
                                      "(#{subquery_sql}))"
      when :has_one : "#{table_name}.#{connection.quote_column_name(primary_key)} IN (#{subquery_sql})"
      when :has_many : "#{table_name}.#{connection.quote_column_name(primary_key)} IN (#{subquery_sql})"
      else raise "Unrecognized Association Macro '#{association.macro}' not supported by FindByAssociation."
      end
 
    end
  end
  
  # Update the dynamic finders to allow referencing association names instead of
  # just column names.
  def method_missing_with_find_by_association(method_id, *arguments)
    match = /^find_(all_by|by)_([_a-zA-Z]\w*)$/.match(method_id.to_s)
    match = /^find_or_(initialize|create)_by_([_a-zA-Z]\w*)$/.match(method_id.to_s) unless match
    if match
      action_type_segment = $1
      attribute_names_segment = $2
      action_type = (action_type_segment =~ /by/) ? :finder : :instantiator
      attribute_names = attribute_names_segment.split(/_and_/)
      options_argument = (arguments.size > attribute_names.size) ? arguments.last : {}
      associations = {}
      index = 0
 
      non_associations = attribute_names.select do |attribute_name|
        attribute_chain = attribute_name.split('_having_')
        attribute_name = attribute_chain.shift
        if reflect_on_association(attribute_name.to_sym)
          associations[attribute_name.to_sym] ||= attribute_chain.reverse.inject(arguments.delete_at(index)){|v,n|{n=>v}}
          false
        else
          index += 1
          true
        end
      end
 
      unless associations.empty?
        find_options = { :conditions => associations }
        set_readonly_option!(find_options)
        with_scope :find => find_options do
          if action_type == :finder
            finder = match.captures.first == 'all_by' ? :find_every : :find_initial
            return __send__(finder, options_argument) if non_associations.empty?
            return __send__("find#{'_all' if finder == :find_every}_by_#{non_associations.join('_and_')}".to_sym, *arguments)
          else
            instantiator = determine_instantiator(match)
            return find_initial(options_argument) || __send__(instantiator, associations) if non_associations.empty?
            return __send__("find_or_#{instantiator}_by_#{non_associations.join('_and_')}".to_sym, *arguments)
          end
        end
      end
    end
 
    method_missing_without_find_by_association method_id, *arguments
 
  end
 
  def options_for_find_by_association_conditions_subquery(association, conditions, options={})
 
    association_type, through_type = options[:type], options[:through_type]
    association = reflect_on_association(association) unless association.is_a? ActiveRecord::Reflection::AssociationReflection
    
    association.options[:polymorphic] and !association_type and
      raise "Polymorphic belongs_to associations require the :type argument for options_for_find_by_association_conditions_subquery."
 
    association_type ||= association_type.is_a?(String) ? association_type.constantize : association.klass
 
    options = {}
    
    key_column = case association.macro
                 when :belongs_to,
                      :has_and_belongs_to_many : association_type.primary_key
                 else association.primary_key_name
                 end
 
    options[:select] = "#{key_column} _id"
    segments = []
    segments << "#{key_column} IS NOT NULL"
    conditions and segments << association_type.__send__(:sanitize_sql, conditions)
    association.options[:conditions] and segments << association_type.__send__(:sanitize_sql, association.options[:conditions])
    association.options[:as] and segments << association_type.__send__(:sanitize_sql, (association_type.reflect_on_association(association.options[:as].to_sym).options[:foreign_type] || :"#{association.options[:as]}_type").to_sym => (through_type||self).name)
    segments.reject! {|c|c.blank?}
    options[:conditions] = segments.size > 1 ? "(#{segments.join(') AND (')})" : segments.first unless segments.empty?
 
    # subqueries in MySQL can not use order or limit
    # options[:order] = association.options[:order] if association.options[:order]
    # options[:limit] = association.options[:limit] if association.options[:limit]
 
    options
 
  end
  
  # This is an affordance to turn on/off the use of a wrapper query that generates
  # an aliased derived table for the purpose of query-plan optimization for some
  # database engines. This hack has been shown to significantly benefit query times
  # for mysql and sqlite3. (has not yet been tested with other engines.)
  def use_derived_table_hack_for_subquery_optimization?
    false
  end
  
  def self.extended(host)
    super
    class << host
      alias_method :sanitize_sql_hash_for_conditions, :sanitize_sql_hash_with_find_by_association
      alias_method_chain :method_missing, :find_by_association
      alias_method_chain :sanitize_sql_hash, :find_by_association
      alias_method_chain :with_scope, :find_by_association
    end
  end
  
end