From 7f3b475608d7c0b7faadd7c1430797e91b644a35 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 1 Aug 2012 19:15:23 +0100 Subject: Revert "Remove :finder_sql, :counter_sql, :insert_sql, :delete_sql." This reverts commit 3803fcce26b837c0117f7d278b83c366dc4ed370. Conflicts: activerecord/CHANGELOG.md It will be deprecated only in 4.0, and removed properly in 4.1. --- .../lib/active_record/associations/association.rb | 8 ++ .../associations/association_scope.rb | 2 +- .../associations/builder/collection_association.rb | 2 +- .../builder/has_and_belongs_to_many.rb | 2 +- .../associations/collection_association.rb | 85 ++++++++++++++++++---- .../has_and_belongs_to_many_association.rb | 39 ++++++---- .../associations/has_many_association.rb | 8 +- 7 files changed, 111 insertions(+), 35 deletions(-) (limited to 'activerecord/lib/active_record/associations') diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 9e464ff681..14755602da 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -140,6 +140,14 @@ module ActiveRecord reset end + def interpolate(sql, record = nil) + if sql.respond_to?(:to_proc) + owner.send(:instance_exec, record, &sql) + else + sql + end + end + # We can't dump @reflection since it contains the scope proc def marshal_dump reflection = @reflection diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index cb97490ef1..1303822868 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -5,7 +5,7 @@ module ActiveRecord attr_reader :association, :alias_tracker - delegate :klass, :owner, :reflection, :to => :association + delegate :klass, :owner, :reflection, :interpolate, :to => :association delegate :chain, :scope_chain, :options, :source_options, :active_record, :to => :reflection def initialize(association) diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index b28d6a746c..af81af4ad2 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -3,7 +3,7 @@ module ActiveRecord::Associations::Builder CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] def valid_options - super + [:table_name, :before_add, :after_add, :before_remove, :after_remove] + super + [:table_name, :finder_sql, :counter_sql, :before_add, :after_add, :before_remove, :after_remove] end attr_reader :block_extension, :extension_module diff --git a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb index a30e2dab26..7f79ef25f2 100644 --- a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -5,7 +5,7 @@ module ActiveRecord::Associations::Builder end def valid_options - super + [:join_table, :association_foreign_key] + super + [:join_table, :association_foreign_key, :delete_sql, :insert_sql] end def build diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 30522e3a5d..14a24e02dc 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -44,7 +44,7 @@ module ActiveRecord # Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items def ids_reader - if loaded? + if loaded? || options[:finder_sql] load_target.map do |record| record.send(reflection.association_primary_key) end @@ -79,7 +79,11 @@ module ActiveRecord if block_given? load_target.find(*args) { |*block_args| yield(*block_args) } else - scoped.find(*args) + if options[:finder_sql] + find_by_scan(*args) + else + scoped.find(*args) + end end end @@ -166,26 +170,35 @@ module ActiveRecord end end - # Count all records using SQL. Construct options and pass them with + # Count all records using SQL. If the +:counter_sql+ or +:finder_sql+ option is set for the + # association, it will be used for the query. Otherwise, construct options and pass them with # scope to the target class's +count+. def count(column_name = nil, count_options = {}) column_name, count_options = nil, column_name if column_name.is_a?(Hash) - if association_scope.uniq_value - # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL. - column_name ||= reflection.klass.primary_key - count_options[:distinct] = true - end + if options[:counter_sql] || options[:finder_sql] + unless count_options.blank? + raise ArgumentError, "If finder_sql/counter_sql is used then options cannot be passed" + end - value = scoped.count(column_name, count_options) + reflection.klass.count_by_sql(custom_counter_sql) + else + if association_scope.uniq_value + # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL. + column_name ||= reflection.klass.primary_key + count_options[:distinct] = true + end - limit = options[:limit] - offset = options[:offset] + value = scoped.count(column_name, count_options) - if limit || offset - [ [value - offset.to_i, 0].max, limit.to_i ].min - else - value + limit = options[:limit] + offset = options[:offset] + + if limit || offset + [ [value - offset.to_i, 0].max, limit.to_i ].min + else + value + end end end @@ -310,6 +323,7 @@ module ActiveRecord if record.new_record? include_in_memory?(record) else + load_target if options[:finder_sql] loaded? ? target.include?(record) : scoped.exists?(record) end else @@ -344,8 +358,31 @@ module ActiveRecord private + def custom_counter_sql + if options[:counter_sql] + interpolate(options[:counter_sql]) + else + # replace the SELECT clause with COUNT(SELECTS), preserving any hints within /* ... */ + interpolate(options[:finder_sql]).sub(/SELECT\b(\/\*.*?\*\/ )?(.*)\bFROM\b/im) do + count_with = $2.to_s + count_with = '*' if count_with.blank? || count_with =~ /,/ + "SELECT #{$1}COUNT(#{count_with}) FROM" + end + end + end + + def custom_finder_sql + interpolate(options[:finder_sql]) + end + def find_target - records = scoped.to_a + records = + if options[:finder_sql] + reflection.klass.find_by_sql(custom_finder_sql) + else + scoped.all + end + records.each { |record| set_inverse_instance(record) } records end @@ -484,6 +521,7 @@ module ActiveRecord # Otherwise, go to the database only if none of the following are true: # * target already loaded # * owner is new record + # * custom :finder_sql exists # * target contains new or changed record(s) # * the first arg is an integer (which indicates the number of records to be returned) def fetch_first_or_last_using_find?(args) @@ -492,6 +530,7 @@ module ActiveRecord else !(loaded? || owner.new_record? || + options[:finder_sql] || target.any? { |record| record.new_record? || record.changed? } || args.first.kind_of?(Integer)) end @@ -508,6 +547,20 @@ module ActiveRecord end end + # If using a custom finder_sql, #find scans the entire collection. + def find_by_scan(*args) + expects_array = args.first.kind_of?(Array) + ids = args.flatten.compact.map{ |arg| arg.to_i }.uniq + + if ids.size == 1 + id = ids.first + record = load_target.detect { |r| id == r.id } + expects_array ? [ record ] : record + else + load_target.select { |r| ids.include?(r.id) } + end + end + # Fetches the first/last using SQL if possible, otherwise from the target array. def first_or_last(type, *args) args.shift if args.first.is_a?(Hash) && args.first.empty? diff --git a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb index e5b40f3911..93618721bb 100644 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb @@ -18,12 +18,16 @@ module ActiveRecord end end - stmt = join_table.compile_insert( - join_table[reflection.foreign_key] => owner.id, - join_table[reflection.association_foreign_key] => record.id - ) + if options[:insert_sql] + owner.connection.insert(interpolate(options[:insert_sql], record)) + else + stmt = join_table.compile_insert( + join_table[reflection.foreign_key] => owner.id, + join_table[reflection.association_foreign_key] => record.id + ) - owner.connection.insert stmt + owner.connection.insert stmt + end record end @@ -35,17 +39,22 @@ module ActiveRecord end def delete_records(records, method) - relation = join_table - condition = relation[reflection.foreign_key].eq(owner.id) - - unless records == :all - condition = condition.and( - relation[reflection.association_foreign_key] - .in(records.map { |x| x.id }.compact) - ) - end + if sql = options[:delete_sql] + records = load_target if records == :all + records.each { |record| owner.connection.delete(interpolate(sql, record)) } + else + relation = join_table + condition = relation[reflection.foreign_key].eq(owner.id) - owner.connection.delete(relation.where(condition).compile_delete) + unless records == :all + condition = condition.and( + relation[reflection.association_foreign_key] + .in(records.map { |x| x.id }.compact) + ) + end + + owner.connection.delete(relation.where(condition).compile_delete) + end end def invertible_for?(record) diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 41c6ca92cc..669b7e03c2 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -33,7 +33,13 @@ module ActiveRecord # If the collection is empty the target is set to an empty array and # the loaded flag is set to true as well. def count_records - count = has_cached_counter? ? owner[cached_counter_attribute_name] : scoped.count + count = if has_cached_counter? + owner.send(:read_attribute, cached_counter_attribute_name) + elsif options[:counter_sql] || options[:finder_sql] + reflection.klass.count_by_sql(custom_counter_sql) + else + scoped.count + end # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a -- cgit v1.2.3