Skip to content

Commit

Permalink
Added new Base.find API and deprecated find_all, find_first. Added pr…
Browse files Browse the repository at this point in the history
…eliminary support for eager loading of associations

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1077 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information
dhh committed Apr 3, 2005
1 parent 17e5035 commit abc895b
Show file tree
Hide file tree
Showing 16 changed files with 243 additions and 227 deletions.
6 changes: 3 additions & 3 deletions activerecord/Rakefile
Expand Up @@ -106,13 +106,13 @@ end

desc "Publish the beta gem"
task :pgem => [:package] do
Rake::SshFilePublisher.new("davidhh@comox.textdrive.com", "public_html/gems/gems", "pkg", "#{PKG_FILE_NAME}.gem").upload
`ssh davidhh@comox.textdrive.com './gemupdate.sh'`
Rake::SshFilePublisher.new("davidhh@wrath.rubyonrails.com", "public_html/gems/gems", "pkg", "#{PKG_FILE_NAME}.gem").upload
`ssh davidhh@wrath.rubyonrails.com './gemupdate.sh'`
end

desc "Publish the API documentation"
task :pdoc => [:rdoc] do
Rake::SshDirPublisher.new("davidhh@comox.textdrive.com", "public_html/ar", "doc").upload
Rake::SshDirPublisher.new("davidhh@wrath.rubyonrails.com", "public_html/ar", "doc").upload
end

desc "Publish the release files to RubyForge."
Expand Down
56 changes: 55 additions & 1 deletion activerecord/lib/active_record/associations.rb
Expand Up @@ -19,7 +19,7 @@ def clear_association_cache #:nodoc:
instance_variable_set "@#{assoc.name}", nil
end
end

# Associations are a set of macro-like class methods for tying objects together through foreign keys. They express relationships like
# "Project has one Project Manager" or "Project belongs to a Portfolio". Each macro adds a number of methods to the class which are
# specialized according to the collection or association symbol and the options hash. It works much the same was as Ruby's own attr*
Expand Down Expand Up @@ -617,6 +617,60 @@ def add_multiple_associated_save_callbacks(association_name)
end_eval
end
end


def find_with_associations(options = {})
reflections = [ options[:include] ].flatten.collect { |association| reflect_on_association(association) }
rows = connection.select_all(construct_finder_sql_with_included_associations(reflections), "#{name} Load Including Associations")
records = rows.collect { |row| instantiate(extract_record(table_name, row)) }.uniq

reflections.each do |reflection|
records.each do |record|
case reflection.macro
when :has_many
record.send(reflection.name).target = extract_association_for_record(record, rows, reflection)
when :has_one, :belongs_to
record.send("#{reflection.name}=", extract_association_for_record(record, rows, reflection).first)
end
end
end

return records
end

def construct_finder_sql_with_included_associations(reflections)
sql = "SELECT #{selected_columns(table_name, columns)}"
reflections.each { |reflection| sql << ", #{selected_columns(reflection.klass.table_name, reflection.klass.columns)}" }
sql << " FROM #{table_name} "
reflections.each do |reflection|
sql << " LEFT JOIN #{reflection.klass.table_name} ON " +
"#{reflection.klass.table_name}.#{table_name.classify.foreign_key} = #{table_name}.#{primary_key}"
end

return sanitize_sql(sql)
end

def extract_association_for_record(record, rows, reflection)
association = rows.collect do |row|
if row["#{table_name}__#{primary_key}"] == record.id.to_s
reflection.klass.send(:instantiate, extract_record(reflection.klass.table_name, row))
end
end

return association.compact
end

def extract_record(table_name, row)
row.inject({}) do |record, pair|
prefix, column_name = pair.first.split("__")
record[column_name] = pair.last if prefix == table_name
record
end
end

def selected_columns(table_name, columns)
columns.collect { |column| "#{table_name}.#{column.name} as #{table_name}__#{column.name}" }.join(", ")
end
end
end
end
Expand Up @@ -31,6 +31,11 @@ def respond_to?(symbol, include_priv = false)
def loaded?
@loaded
end

def target=(t)
@target = t
@loaded = true
end

protected
def dependent?
Expand Down
121 changes: 49 additions & 72 deletions activerecord/lib/active_record/base.rb
@@ -1,4 +1,5 @@
require 'yaml'
require 'active_record/deprecated_finders'

module ActiveRecord #:nodoc:
class ActiveRecordError < StandardError #:nodoc:
Expand Down Expand Up @@ -301,93 +302,54 @@ class << self # Class methods
#
# +RecordNotFound+ is raised if no record can be found.
def find(*args)
# Return an Array if ids are passed in an Array.
expects_array = args.first.kind_of?(Array)

# Extract options hash from argument list.
options = extract_options_from_args!(args)
conditions = " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]

ids = args.flatten.compact.uniq
case ids.size

# Raise if no ids passed.
when 0
raise RecordNotFound, "Couldn't find #{name} without an ID#{conditions}"

# Find a single id.
when 1
unless result = find_first("#{primary_key} = #{sanitize(ids.first)}#{conditions}")
raise RecordNotFound, "Couldn't find #{name} with ID=#{ids.first}#{conditions}"
end

# Box result if expecting array.
expects_array ? [result] : result

# Find multiple ids.
case args.first
when :first
find(:all, options.merge({ :limit => 1 })).first
when :all
options[:include] ? find_with_associations(options) : find_by_sql(construct_finder_sql(options))
else
ids_list = ids.map { |id| sanitize(id) }.join(',')
result = find_all("#{primary_key} IN (#{ids_list})#{conditions}", primary_key)
if result.size == ids.size
result
else
raise RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids_list})#{conditions}"
expects_array = args.first.kind_of?(Array)
conditions = " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]

ids = args.flatten.compact.uniq
case ids.size
when 0
raise RecordNotFound, "Couldn't find #{name} without an ID#{conditions}"
when 1
if result = find(:first, options.merge({ :conditions => "#{primary_key} = #{sanitize(ids.first)}#{conditions}" }))
return expects_array ? [ result ] : result
else
raise RecordNotFound, "Couldn't find #{name} with ID=#{ids.first}#{conditions}"
end
else
# Find multiple ids
ids_list = ids.map { |id| sanitize(id) }.join(',')
result = find(:all, options.merge({ :conditions => "#{primary_key} IN (#{ids_list})#{conditions}", :order => primary_key }))
if result.size == ids.size
return result
else
raise RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids_list})#{conditions}"
end
end
end
end

# Returns true if the given +id+ represents the primary key of a record in the database, false otherwise.
# Example:
# Person.exists?(5)
def exists?(id)
!find_first("#{primary_key} = #{sanitize(id)}").nil? rescue false
end

# This method is deprecated in favor of find with the :conditions option.
# Works like find, but the record matching +id+ must also meet the +conditions+.
# +RecordNotFound+ is raised if no record can be found matching the +id+ or meeting the condition.
# Example:
# Person.find_on_conditions 5, "first_name LIKE '%dav%' AND last_name = 'heinemeier'"
def find_on_conditions(ids, conditions)
find(ids, :conditions => conditions)
end

# Returns an array of all the objects that could be instantiated from the associated
# table in the database. The +conditions+ can be used to narrow the selection of objects (WHERE-part),
# such as by "color = 'red'", and arrangement of the selection can be done through +orderings+ (ORDER BY-part),
# such as by "last_name, first_name DESC". A maximum of returned objects and their offset can be specified in
# +limit+ with either just a single integer as the limit or as an array with the first element as the limit,
# the second as the offset. Examples:
# Project.find_all "category = 'accounts'", "last_accessed DESC", 15
# Project.find_all ["category = ?", category_name], "created ASC", [15, 20]
def find_all(conditions = nil, orderings = nil, limit = nil, joins = nil)
sql = "SELECT * FROM #{table_name} "
sql << "#{joins} " if joins
add_conditions!(sql, conditions)
sql << "ORDER BY #{orderings} " unless orderings.nil?

limit = sanitize_sql(limit) if limit.is_a? Array and limit.first.is_a? String
connection.add_limit!(sql, limit) if limit

find_by_sql(sql)
end

# Works like find_all, but requires a complete SQL string. Examples:
# Post.find_by_sql "SELECT p.*, c.author FROM posts p, comments c WHERE p.id = c.post_id"
# Post.find_by_sql ["SELECT * FROM posts WHERE author = ? AND created > ?", author_id, start_date]
def find_by_sql(sql)
connection.select_all(sanitize_sql(sql), "#{name} Load").inject([]) { |objects, record| objects << instantiate(record) }
end

# Returns the object for the first record responding to the conditions in +conditions+,
# such as "group = 'master'". If more than one record is returned from the query, it's the first that'll
# be used to create the object. In such cases, it might be beneficial to also specify
# +orderings+, like "income DESC, name", to control exactly which record is to be used. Example:
# Employee.find_first "income > 50000", "income DESC, name"
def find_first(conditions = nil, orderings = nil, joins = nil)
find_all(conditions, orderings, 1, joins).first
# Returns true if the given +id+ represents the primary key of a record in the database, false otherwise.
# Example:
# Person.exists?(5)
def exists?(id)
!find_first("#{primary_key} = #{sanitize(id)}").nil? rescue false
end

# Creates an object, instantly saves it as a record (if the validation permits it), and returns it. If the save
# fail under validations, the unsaved object is still returned.
def create(attributes = nil)
Expand Down Expand Up @@ -739,6 +701,21 @@ def type_name_with_module(type_name)
self.name =~ /::/ ? self.name.scan(/(.*)::/).first.first + "::" + type_name : type_name
end

def construct_finder_sql(options)
sql = "SELECT * FROM #{table_name} "
sql << "#{options[:joins]} " if options[:joins]
add_conditions!(sql, options[:conditions])
sql << "ORDER BY #{options[:order]} " if options[:order]

if options[:limit] && options[:offset]
connection.add_limit_with_offset!(sql, options[:limit].to_i, options[:offset].to_i)
elsif options[:limit]
connection.add_limit_without_offset!(sql, options[:limit].to_i)
end

return sql
end

# Adds a sanitized version of +conditions+ to the +sql+ string. Note that it's the passed +sql+ string is changed.
def add_conditions!(sql, conditions)
sql << "WHERE #{sanitize_sql(conditions)} " unless conditions.nil?
Expand Down
41 changes: 41 additions & 0 deletions activerecord/lib/active_record/deprecated_finders.rb
@@ -0,0 +1,41 @@
module ActiveRecord
class Base # :nodoc:
class << self
# This method is deprecated in favor of find with the :conditions option.
#
# Works like find, but the record matching +id+ must also meet the +conditions+.
# +RecordNotFound+ is raised if no record can be found matching the +id+ or meeting the condition.
# Example:
# Person.find_on_conditions 5, "first_name LIKE '%dav%' AND last_name = 'heinemeier'"
def find_on_conditions(ids, conditions)
find(ids, :conditions => conditions)
end

# This method is deprecated in favor of find(:first, options).
#
# Returns the object for the first record responding to the conditions in +conditions+,
# such as "group = 'master'". If more than one record is returned from the query, it's the first that'll
# be used to create the object. In such cases, it might be beneficial to also specify
# +orderings+, like "income DESC, name", to control exactly which record is to be used. Example:
# Employee.find_first "income > 50000", "income DESC, name"
def find_first(conditions = nil, orderings = nil, joins = nil)
find(:first, :conditions => conditions, :order => orderings, :joins => joins)
end

# This method is deprecated in favor of find(:all, options).
#
# Returns an array of all the objects that could be instantiated from the associated
# table in the database. The +conditions+ can be used to narrow the selection of objects (WHERE-part),
# such as by "color = 'red'", and arrangement of the selection can be done through +orderings+ (ORDER BY-part),
# such as by "last_name, first_name DESC". A maximum of returned objects and their offset can be specified in
# +limit+ with either just a single integer as the limit or as an array with the first element as the limit,
# the second as the offset. Examples:
# Project.find_all "category = 'accounts'", "last_accessed DESC", 15
# Project.find_all ["category = ?", category_name], "created ASC", [15, 20]
def find_all(conditions = nil, orderings = nil, limit = nil, joins = nil)
limit, offset = limit.is_a?(Array) ? limit : [ limit, nil ]
find(:all, { :conditions => conditions, :order => orderings, :joins => joins, :limit => limit, :offset => offset})
end
end
end
end
24 changes: 23 additions & 1 deletion activerecord/test/associations_test.rb
Expand Up @@ -5,6 +5,9 @@
require 'fixtures/topic'
require 'fixtures/reply'
require 'fixtures/computer'
require 'fixtures/post'
require 'fixtures/comment'
require 'fixtures/author'

# Can't declare new classes in test case methods, so tests before that
bad_collection_keys = false
Expand Down Expand Up @@ -203,8 +206,9 @@ def test_assignment_before_either_saved


class HasManyAssociationsTest < Test::Unit::TestCase
fixtures :accounts, :companies, :developers, :projects, :developers_projects, :topics, :posts, :comments

def setup
create_fixtures "accounts", "companies", "developers", "projects", "developers_projects", "topics"
@signals37 = Firm.find(1)
end

Expand Down Expand Up @@ -530,6 +534,24 @@ def test_included_in_collection
def test_adding_array_and_collection
assert_nothing_raised { Firm.find_first.clients + Firm.find_all.last.clients }
end

def test_eager_association_loading_with_one_association
posts = Post.find(:all, :include => :comments)
assert_equal 2, posts.first.comments.size
assert_equal @greetings.body, posts.first.comments.first.body
end

def test_eager_association_loading_with_multiple_associations
posts = Post.find(:all, :include => [ :comments, :author ])
assert_equal 2, posts.first.comments.size
assert_equal @greetings.body, posts.first.comments.first.body
end

def xtest_eager_association_loading_with_belongs_to
comments = Comment.find(:all, :include => :post)
assert_equal @welcome.title, comments.first.post.title
assert_equal @thinking.title, comments.last.post.title
end
end

class BelongsToAssociationsTest < Test::Unit::TestCase
Expand Down

0 comments on commit abc895b

Please sign in to comment.