Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Ticket #7147: init.5.rb

File init.5.rb, 13.7 kB (added by mrj, 8 months ago)
Line 
1 # This monkey-patch/plugin allows a restricted set of attributes to be selected for
2 # each model in an ActiveRecord find call that eager loads associations.
3 # Either
4 #   1. Place this file in Rails lib folder and require it in environment.rb, or
5 #   2. Place file as vendor/plugins/include-attrs/init.rb
6 #
7 # Attributes selected for the base model on which the find is run are selected with
8 # the :select option to find.  Fields must be prefixed with the base model table name
9 # if that field name is present in the table of an eager loaded model. Names of selected
10 # fields (the field name, or the alias if aliased) cannot begin with an underscore.
11 # Selection of arbitary SQL expressions as base model attributes is permitted.
12 #
13 # Attributes on eager-loaded models are specified in the :include option of find
14 # by providing a list of attribute names inside a pair of square brackets that follow
15 # a symbolic association name. No attributes are selected if the list is empty
16 # (useful for using the join for the association in the find :conditions option).
17 #
18 # e.g.
19 #
20 # Store.find :all,
21 #   :select => 'stores.name, owners.name, sum(books.price * books.stock) as total_inventory',
22 #   :include => [:manager[:name], :address, {:books[:title, :stock, :price] => :author}],
23 #   :joins => 'join owners on stores.owner_id = owners.id',
24 #   :conditions => "addresses.city = 'Sydney'",
25 #   :group => 'stores.id'
26
27
28 # Class for holding arrays that specify the attributes to be selected for
29 # an eager-loaded model.  Method response is heavily restricted to prevent
30 # accidental use of the [] method on a symbol from remaining undetected.
31 #
32 class IncludedAssocAttrSelector
33   instance_methods.each { |m| undef_method m unless m =~ /^(__|hash$)/ }
34   attr_reader :_association, :_attrs
35  
36   def initialize(association, attrs, cont)
37     @_association = association
38     @_attrs = attrs.map(&:to_s)
39     @cont = cont
40   end
41  
42   def to_s
43     ":#{@_association}#{@_attrs.inspect}"
44   end
45   alias :inspect :to_s
46  
47   def method_missing(symbol)
48     @cont.call
49   end
50 end
51
52 Symbol.class_eval do
53   alias_method :orig_sq, :[] if method_defined?(:[])
54   def [](*attrs)
55     callcc { |cont| return IncludedAssocAttrSelector.new(self, attrs, cont) }
56     Symbol.method_defined?(:orig_sq) ? orig_sq(*attrs) : method_missing(:[], *attrs)
57   end
58 end
59
60 class ActiveRecord::Base
61
62   # Just change the processing of the :select option
63   #
64   def self.construct_finder_sql_with_included_associations(options, join_dependency)
65     scope = scope(:find)
66     base_select = options[:select] || scope && scope[:select] || "#{table_name}.*"
67     eager_select = eager_select(join_dependency)
68     base_select += ', ' unless base_select.empty? || eager_select.empty?
69     sql = "SELECT #{base_select + eager_select} FROM #{(scope && scope[:from]) || options[:from] || table_name} "
70     sql << join_dependency.joins.map { |join| join.association_join }.join
71
72     add_joins!(sql, options, scope)
73     add_conditions!(sql, options[:conditions], scope)
74     add_limited_ids_condition!(sql, options, join_dependency) if !using_limitable_reflections?(join_dependency.reflections) && ((scope && scope[:limit]) || options[:limit])
75
76     sql << "GROUP BY #{options[:group]} " if options[:group]
77
78     add_order!(sql, options[:order], scope)
79     add_limit!(sql, options, scope) if using_limitable_reflections?(join_dependency.reflections)
80     add_lock!(sql, options, scope)
81
82     return sanitize_sql(sql)
83   end
84
85   # The exsiting column_aliases method renamed
86   #
87   def self.eager_select(join_dependency)
88     join_dependency.joins.map do |join|
89       join.column_names_with_alias.map do |column_name, aliased_name|
90         "#{join.aliased_table_name}.#{connection.quote_column_name column_name} AS #{aliased_name}"
91       end
92     end.flatten.join(', ')
93   end 
94 end
95
96 class ActiveRecord::Associations::ClassMethods::JoinDependency
97
98   # Just add the table_joins, base, and base_id instance variables,
99   # and move the JoinBase from the joins to the build call.
100   #
101  
102   attr_reader :table_joins
103  
104   def initialize(base, associations, joins)
105     @base                  = base
106     @base_id               = base.primary_key
107     @joins                 = []
108     @join_base = JoinBase.new(base)
109     @table_joins           = joins
110     @associations          = associations
111     @reflections           = []
112     @base_records_hash     = {}
113     @base_records_in_order = []
114     @table_aliases         = Hash.new { |aliases, table| aliases[table] = 0 }
115     @table_aliases[base.table_name] = 1
116     build(associations, @join_base)
117   end
118  
119   # Base attrs are those not having an underscore as their first character.
120   #
121   def instantiate(rows)
122     j = joins.map { |j| j.reflection.name }.join(', ')
123     rows.each do |row|
124       primary_id = row[@base_id]
125       unless @base_records_hash[primary_id]
126         base_attrs = row.reject { |attr, value| attr[0] == ?_ }
127         @base_records_in_order << (@base_records_hash[primary_id] = @base.send(:instantiate, base_attrs))
128       end
129       @join_index = -1
130       construct(@base_records_hash[primary_id], @associations, joins, row)
131     end
132     remove_duplicate_results!(@base, @base_records_in_order, @associations)
133     return @base_records_in_order
134   end
135  
136   def join_associations() @joins end
137   def join_base() @join_base end
138  
139   protected
140
141     # Handle the :assoc[:attr1, :attr2, ...] syntax for attribute selection on eager-loaded models
142     #
143     def build(associations, parent)
144       case associations
145         when Symbol, String, IncludedAssocAttrSelector
146           attrs = nil
147           if IncludedAssocAttrSelector === associations
148             attrs = associations._attrs
149             associations = associations._association
150           end
151           unless reflection = parent.reflections[associations.to_s.intern]
152             raise ActiveRecord::ConfigurationError, "Association named '#{ associations }' was not found; perhaps you misspelled it?"
153           end
154           @reflections << reflection
155           @joins << (join_assoc = build_join_association(reflection, parent, attrs))
156           join_assoc
157         when Array
158           associations.each do |association|
159             build(association, parent)
160           end
161         when Hash
162           associations.keys.sort{|a,b|a.to_s<=>b.to_s}.each do |name|
163             build(associations[name], build(name, parent))
164           end
165         else
166           raise ActiveRecord::ConfigurationError, associations.inspect
167       end
168     end
169    
170     def build_join_association(reflection, parent, attrs)
171       JoinAssociation.new(reflection, self, parent, attrs)
172     end
173    
174     # Handle IncludedAssocAttrSelector include specs
175     # and avoid having to dup the joins array by keeping an index rather than shifting.
176     #
177     def construct(parent, associations, joins, row)
178      
179       case associations
180         when Symbol, String, IncludedAssocAttrSelector
181           associations = associations._association if IncludedAssocAttrSelector === associations
182           while (join = joins[@join_index += 1]).reflection.name.to_s != associations.to_s
183             raise ActiveRecord::ConfigurationError, "Not Enough Associations" if join.nil?
184           end
185           construct_association(parent, join, row) if parent
186         when Array
187           associations.each do |association|
188             construct(parent, association, joins, row)
189           end
190         when Hash
191           associations.keys.sort{|a,b|a.to_s<=>b.to_s}.each do |name|
192             association = construct_association(parent, joins[@join_index+=1], row) if parent
193             construct(association, associations[name], joins, row)
194           end
195         else
196           raise ActiveRecord::ConfigurationError, associations.inspect
197       end
198     end
199    
200     # Handle IncludedAssocAttrSelector include specs
201     #
202     def remove_duplicate_results!(base, records, associations)
203       case associations
204         when Symbol, String, IncludedAssocAttrSelector
205           associations = associations._association if IncludedAssocAttrSelector === associations
206           reflection = base.reflections[associations]
207           if reflection && [:has_many, :has_and_belongs_to_many].include?(reflection.macro)
208             records.each { |record| record.send(reflection.name).target.uniq! }
209           end
210         when Array
211           associations.each do |association|
212             remove_duplicate_results!(base, records, association)
213           end
214         when Hash
215           associations.keys.each do |name|
216             name = name._association if IncludedAssocAttrSelector === name
217             reflection = base.reflections[name]
218             is_collection = [:has_many, :has_and_belongs_to_many].include?(reflection.macro)
219
220             parent_records = records.map do |record|
221               next unless record.send(reflection.name)
222               is_collection ? record.send(reflection.name).target.uniq! : record.send(reflection.name)
223             end.flatten.compact
224
225             remove_duplicate_results!(reflection.class_name.constantize, parent_records, associations[name]) unless parent_records.empty?
226           end
227       end
228     end
229
230     # Just add a return(nil) if no attributes have been selected
231     #
232     def construct_association(record, join, row)
233       return nil if join.selected_attrs.empty?
234       case join.reflection.macro
235         when :has_many, :has_and_belongs_to_many
236           collection = record.send(join.reflection.name)
237           collection.loaded
238           return nil if record.id.to_s != join.parent.record_id(row).to_s or row[join.aliased_primary_key].nil?
239           association = join.instantiate(row)
240           collection.target.push(association) unless collection.target.include?(association)
241         when :has_one
242           return if record.id.to_s != join.parent.record_id(row).to_s
243           association = join.instantiate(row) unless row[join.aliased_primary_key].nil?
244           record.send("set_#{join.reflection.name}_target", association)
245         when :belongs_to
246           return if record.id.to_s != join.parent.record_id(row).to_s or row[join.aliased_primary_key].nil?
247           association = join.instantiate(row)
248           record.send("set_#{join.reflection.name}_target", association)
249         else
250           raise ActiveRecord::ConfigurationError, "unknown macro: #{join.reflection.macro}"
251       end
252       return association
253     end
254    
255   # Base table attrs are no longer aliased to t0_rn.
256   #
257   class JoinBase
258     def aliased_prefix
259       aliased_table_name
260     end
261
262     def aliased_primary_key
263       active_record.primary_key
264     end
265   end
266
267   class JoinAssociation
268    
269     # Just add the selected_attrs parameter, instance_variable, and reader method
270     # and move the table_joins (manual joins) from parent.table_joins to the join_dependency,
271     #
272     attr_reader :selected_attrs
273    
274     def initialize(reflection, join_dependency, parent, selected_attrs = nil)
275       reflection.check_validity!
276       if reflection.options[:polymorphic]
277         raise EagerLoadPolymorphicError.new(reflection)
278       end
279
280       super(reflection.klass)
281       @selected_attrs     = selected_attrs || column_names
282       @parent             = parent
283       @reflection         = reflection
284       @aliased_prefix     = "t#{ join_dependency.joins.size }"
285       @aliased_table_name = table_name #.tr('.', '_') # start with the table name, sub out any .'s
286       @parent_table_name  = parent.active_record.table_name
287
288       if !join_dependency.table_joins.blank? && join_dependency.table_joins.to_s.downcase =~ %r{join(\s+\w+)?\s+#{aliased_table_name.downcase}\son}
289         join_dependency.table_aliases[aliased_table_name] += 1
290       end
291
292       unless join_dependency.table_aliases[aliased_table_name].zero?
293         # if the table name has been used, then use an alias
294         @aliased_table_name = active_record.connection.table_alias_for "#{pluralize(reflection.name)}_#{parent_table_name}"
295         table_index = join_dependency.table_aliases[aliased_table_name]
296         join_dependency.table_aliases[aliased_table_name] += 1
297         @aliased_table_name = @aliased_table_name[0..active_record.connection.table_alias_length-3] + "_#{table_index+1}" if table_index > 0
298       else
299         join_dependency.table_aliases[aliased_table_name] += 1
300       end
301
302       if reflection.macro == :has_and_belongs_to_many || (reflection.macro == :has_many && reflection.options[:through])
303         @aliased_join_table_name = reflection.macro == :has_and_belongs_to_many ? reflection.options[:join_table] : reflection.through_reflection.klass.table_name
304         unless join_dependency.table_aliases[aliased_join_table_name].zero?
305           @aliased_join_table_name = active_record.connection.table_alias_for "#{pluralize(reflection.name)}_#{parent_table_name}_join"
306           table_index = join_dependency.table_aliases[aliased_join_table_name]
307           join_dependency.table_aliases[aliased_join_table_name] += 1
308           @aliased_join_table_name = @aliased_join_table_name[0..active_record.connection.table_alias_length-3] + "_#{table_index+1}" if table_index > 0
309         else
310           join_dependency.table_aliases[aliased_join_table_name] += 1
311         end
312       end
313     end
314
315     # Moved from JoinBase. Now only applicable to JoinAssociations
316     #           
317     def aliased_primary_key
318       "_#{ aliased_prefix }_r0"
319     end
320    
321     # Restrict the fields selected for this join's table_alias if specified in the find :select option
322     #
323     def column_names_with_alias
324       unless @column_names_with_alias
325         @column_names_with_alias = []
326         ([primary_key] + (@selected_attrs - [primary_key])).each_with_index do |column_name, i|
327            @column_names_with_alias << [column_name, "_#{ aliased_prefix }_r#{ i }"] unless @selected_attrs.empty?
328         end
329       end
330       return @column_names_with_alias
331     end 
332   end
333 end
334
335
336 class ActiveRecord::Associations::ClassMethods::InnerJoinDependency
337   protected
338     def build_join_association(reflection, parent, attrs)
339       InnerJoinAssociation.new(reflection, self, parent)
340     end
341 end