aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib
diff options
context:
space:
mode:
Diffstat (limited to 'activerecord/lib')
-rw-r--r--activerecord/lib/active_record/associations.rb31
-rw-r--r--activerecord/lib/active_record/associations/association.rb8
-rw-r--r--activerecord/lib/active_record/associations/association_scope.rb2
-rw-r--r--activerecord/lib/active_record/associations/builder/collection_association.rb2
-rw-r--r--activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb2
-rw-r--r--activerecord/lib/active_record/associations/collection_association.rb85
-rw-r--r--activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb39
-rw-r--r--activerecord/lib/active_record/associations/has_many_association.rb8
8 files changed, 142 insertions, 35 deletions
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb
index edf82eb170..774b1ec201 100644
--- a/activerecord/lib/active_record/associations.rb
+++ b/activerecord/lib/active_record/associations.rb
@@ -1108,6 +1108,15 @@ module ActiveRecord
# a +belongs_to+, and the records which get deleted are the join records, rather than
# the associated records.
#
+ # [:finder_sql]
+ # Specify a complete SQL statement to fetch the association. This is a good way to go for complex
+ # associations that depend on multiple tables. May be supplied as a string or a proc where interpolation is
+ # required. Note: When this option is used, +find_in_collection+
+ # is _not_ added.
+ # [:counter_sql]
+ # Specify a complete SQL statement to fetch the size of the association. If <tt>:finder_sql</tt> is
+ # specified but not <tt>:counter_sql</tt>, <tt>:counter_sql</tt> will be generated by
+ # replacing <tt>SELECT ... FROM</tt> with <tt>SELECT COUNT(*) FROM</tt>.
# [:extend]
# Specify a named module for extending the proxy. See "Association extensions".
# [:include]
@@ -1179,6 +1188,14 @@ module ActiveRecord
# has_many :tags, :as => :taggable
# has_many :reports, :readonly => true
# has_many :subscribers, :through => :subscriptions, :source => :user
+ # has_many :subscribers, :class_name => "Person", :finder_sql => Proc.new {
+ # %Q{
+ # SELECT DISTINCT *
+ # FROM people p, post_subscriptions ps
+ # WHERE ps.post_id = #{id} AND ps.person_id = p.id
+ # ORDER BY p.first_name
+ # }
+ # }
def has_many(name, scope = nil, options = {}, &extension)
Builder::HasMany.build(self, name, scope, options, &extension)
end
@@ -1542,6 +1559,18 @@ module ActiveRecord
# such as <tt>last_name, first_name DESC</tt>
# [:uniq]
# If true, duplicate associated objects will be ignored by accessors and query methods.
+ # [:finder_sql]
+ # Overwrite the default generated SQL statement used to fetch the association with a manual statement
+ # [:counter_sql]
+ # Specify a complete SQL statement to fetch the size of the association. If <tt>:finder_sql</tt> is
+ # specified but not <tt>:counter_sql</tt>, <tt>:counter_sql</tt> will be generated by
+ # replacing <tt>SELECT ... FROM</tt> with <tt>SELECT COUNT(*) FROM</tt>.
+ # [:delete_sql]
+ # Overwrite the default generated SQL statement used to remove links between the associated
+ # classes with a manual statement.
+ # [:insert_sql]
+ # Overwrite the default generated SQL statement used to add links between the associated classes
+ # with a manual statement.
# [:extend]
# Anonymous module for extending the proxy, see "Association extensions".
# [:include]
@@ -1578,6 +1607,8 @@ module ActiveRecord
# has_and_belongs_to_many :nations, :class_name => "Country"
# has_and_belongs_to_many :categories, :join_table => "prods_cats"
# has_and_belongs_to_many :categories, :readonly => true
+ # has_and_belongs_to_many :active_projects, :join_table => 'developers_projects', :delete_sql =>
+ # proc { |record| "DELETE FROM developers_projects WHERE active=1 AND developer_id = #{id} AND project_id = #{record.id}" }
def has_and_belongs_to_many(name, scope = nil, options = {}, &extension)
Builder::HasAndBelongsToMany.build(self, name, scope, options, &extension)
end
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