Ruby on Rails 研究
ActiveRecord (1) -- construct_sql
下面是 lib/active_record/associations 目录中的 has_many_association.rb 的摘录。但是为了让源代码在页面里全部显示出来,做了一些修改。介绍的源代码全是 ActiveRecord 2.1.0 的东西。
module ActiveRecord
module Associations
class HasManyAssociation < AssociationCollection #:nodoc:
# (省略)
protected
def construct_sql
case
when @reflection.options[:finder_sql]
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
when @reflection.options[:as]
@finder_sql =
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " +
"#{@owner.quoted_id} AND " +
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " +
"#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}"
@finder_sql << " AND (#{conditions})" if conditions
else
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " +
"#{@owner.quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
end
if @reflection.options[:counter_sql]
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
elsif @reflection.options[:finder_sql]
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
@reflection.options[:counter_sql] =
@reflection.options[:finder_sql].sub(
/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
else
@counter_sql = @finder_sql
end
end
# (省略)
end
end
end
定义方法 construct_sql 。HasManyAssociation 类的实例被生成的时候,调用了这个方法。
该方法的目的是将 SQ L语句的片段存储进 2 个实例变量 @finder_sql 和 @counter_sql 里。这些 SQL 语句的片段将在 find 和 count 方法中被使用。
请先看开头部分。
module ActiveRecord
module Associations
class HasManyAssociation < AssociationCollection #:nodoc:
在 ActiveRecord::Associations 模块下生成类 HasManyAssociation 。
在Ruby语言里“模块”最重要的作用是提供名称空间。在类名名不起冲突的同时,提示读者类是何种类的事物。
HasManyAssociation 类继承了 AssociationCollection 类。Ruby 语言里用 < 表示类与类之间的继承关系。
右边的 #:nodoc 是 RDoc 的修饰符(modifier)的一种。RDoc 是从 Ruby 源代码生成文档的程序。RDcoc 将添加了这个修饰符的类与方法从文档生成的对象中排除开去。
HasManyAssociation 的母类又继承 AssociationProxy 类。这个类的作用是什么呢?
请参阅下一个例子。
class Club < ActiveRecord::Base has_many :members end club = Club.find(:first) members = club.members
变量 members 中存储的是对象 AssociationProxy 。
咋一看这个对象好像是 Array 对象。尝试解析 members.class.name ,会返回 Array 字符串。不过,不能被欺骗。
请看下面的源代码。
class AssociationProxy #:nodoc:
alias_method :proxy_respond_to?, :respond_to?
alias_method :proxy_extend, :extend
delegate :to_param, :to => :proxy_target
instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?$|^send$|proxy_|^object_id$)/ }
# (中略)
private
def method_missing(method, *args)
if load_target
if block_given?
@target.send(method, *args) { |*block_args| yield(*block_args) }
else
@target.send(method, *args)
end
end
end
AssociationProxy 用 undef_method,将从 Object 类继承的方法中,名称不符合正则表达式的方法全部删去(第5行)。当然,class方法也消失了。这是因为如果我们调用class类,就会被 method_missing 方法拦截,结果调用了 @target 的 class 方法。@target 的内容是数组,所以最终 Array 字符串返回来。
这里要注意的是,method_missing 方法中调用了 load_target 方法这一事实。以这个方法为起点, SQL 语句被发向数据库管理系统,值被存储在@target中 。
我们虽然用 club.members.find(:all, :order => 'created_at') 这样的写法,但在解析到 club.members 的时候,SQL 一被发送,就会导致无用的数据库访问发生。所以应该防止这种情况发生。
返回到主题中的源代码。
protected
def construct_sql
protected 看起来像是 Ruby 语言特别的关键词,但其实 Module 类的实例方法。
将在此之后定义的方法的可视性 Protected。
Ruby语言里有三种可视性(public,protected,public),和Java语言相同,但意思却很不一样。
Java 的情况下, private 方法只能从同一类调用。Protected 的话,就能从其子类以及属于同一包下的类调用。
Ruby的话,不管是 private 方法还是 protected 方法,只能从同一类以及子类调用。但是,private 方法不能以接收器形式(在对象后添加点和方法名的写法)调用。
实际上必须使用 Ruby 语言的 protected 的情况不是太多 (能用 private 代用),但是在 Ruby on Rails 的源代码里却被多次使用。就笔者所调查,construct_sql 方法即使是 private 好像也没有什么问题。不太清楚设成 protected 的理由。可能因为做一样事情的 HasOneAssociation 类的 construct_sql 方法设成了 private,所以可能仅仅是弄错了吧。
顺便提一句,对 ActiveRecord 的编码样式中特别感兴趣的一点是,将 protected 的声明后面的缩进后退了 1 段(大概 2 个字空白)
我想这是明示方法非 public 的处理,但是看起来好像是类定义的末尾缩进错位,所以笔者感觉有些不舒服。。
但是,Ruby on Rails 其他组件 (ActionController等等) 的源代码也都一贯遵守这法则,所以好像被确立为 Rails 开发者们的守则了吧。。
进行下面的吧。
case
when @reflection.options[:finder_sql]
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
when @reflection.options[:as]
@finder_sql =
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " +
"#{@owner.quoted_id} AND " +
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " +
"#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}"
@finder_sql << " AND (#{conditions})" if conditions
else
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " +
"#{@owner.quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
end
通常,实例变量 @reflection 里包含 ActiveRecord::Reflection::AssociationReflection 对象。
reflection 这个英语单词,一般是“(光和音的)反射”,“反省”的意思,在Rails中作为和“元数据”相近的意思来使用。也就是,用来保持某个模型与其它模型的关联 (association)的信息的对象。例如关联的这两个模型的类名,赋予 has_many 方法的选项被存储进 @reflection 中。
另外,上述源代码的片断中用了 case 式。
Ruby 语言的 case 式,有 if 式替代品的作用,还有与 C 语言,JAVA 语言的 switch 语句相近的作用,这里的作用是前者。
如果使用 if 重新写的话,变为如下。
if @reflection.options[:finder_sql]
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
elsif @reflection.options[:as]
@finder_sql =
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " +
"#{@owner.quoted_id} AND " +
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " +
"#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}"
@finder_sql << " AND (#{conditions})" if conditions
else
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " +
"#{@owner.quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
end
不明白 ActiveRecord 的作者使用 case 的理由。
@reflection.options 里存有赋予 has_many 方法的选项。:finder_sql 选项旨在指定表示表之间关联的 SQL 代码。看起来就那样存储在 @finder_sql 就行,其实是通过了、interpolate_sql 方法。
这个方法是 AssociationProxy 的实例方法。
def interpolate_sql(sql, record = nil)
@owner.send(:interpolate_sql, sql, record)
end
这里 @owner 是 ActiveRecord::Base 对象。定义如下。
def interpolate_sql(sql, record = nil)
instance_eval("%@#{sql.gsub('@', '\@')}@")
end
英语单词 interpolate 作为方法名使用是不太常见的。根据英日字典是“篡改文章”的意思,作为 Ruby 用语的话,是指在字符串中用 #{ ... } 记法嵌入式子。
interpolate_sql 方法的参数 sql 里存储着下面的字符串。
members.club_id = #{id}
于是, instance_eval 方法中下面的字符串被传递。
%@members.club_id = #{id}@
instance_eval 方法,将此作为 Ruby 代码,在此实例中评价后返回。%@ ... @ 表示字符串的开始和结束。与 %Q{ ... } 相同。
总之,就形成了
members.club_id = 123
这样的字符串。但是,123 是这个实例的id。
接下来的 when 进行在 has_many 方法中 :as 选项被指定时的处理,这个选择是为了指定 polymorphic associations ,笔者没用过这个,所以省略说明。(^^;)
最后的 else 的内容如下。
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " +
"#{@owner.quoted_id}"
@finder_sql << " AND (#{conditions})" if conditions
@reflection.quoted_table_name 返回用引用符引用的相关表的名称。
以前面出现的模型 Club 为例的话,相关表是 members。引用表名的引用符因数据库管理系统而各异。MySQL 的情况下是反引号(`),所以成为`members` 。
@reflection.primary_key_name ,返回从 members 表到 clubs 表的外部键的名称。因为表是按照 Rails 规则设计,所以成为club_id 。
@owner.quoted_id 是相关原始记录主键的值。虽然有 "quoted",但是因为主键的值是 Fixnum ,所以照样通过 to_s 变为字符串。
总结起来的话,@finder_sql 中便存储了 `members`.user_id = 123 的字符串 。conditions不是 nil 的话,便通过AND 添加到 @finder_sql 的末尾。
如果到这儿能理解的话,剩下的就没那么难了。
if @reflection.options[:counter_sql]
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
elsif @reflection.options[:finder_sql]
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
@reflection.options[:counter_sql] =
@reflection.options[:finder_sql].sub(
/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
else
@counter_sql = @finder_sql
end
稍微有一点麻烦的是,通过 sub 方法使用的正则表达式。
/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im
(\/\*.*?\*\/ )? 的部分,是嵌在 SQL 语句里的注释。虽然不敢说想在 :finder_sql 选项指定添加了注释的 SQL 语句的人有很多,如果特意从保存注释的地方来考虑,根据注释行为改变的数据库管理系统应该存在(知道的人请务必赐教!)。
\b 表示词字符串与非词字符串的分界线(boundary)。词字符串,是指字符串类 [A-Za-z9-0_] 。
末尾的 im 是正则表达选项,是“不区分大小写 (i)”,“使点号(.)匹配换行字符(m)”的意思。
--
黒田努
(2008/07/20)
- 前言
- ActiveRecord (1) -- construct_sql (2008/07/20)
- 阅读Rails 2.2 的 ActiveRecord::Validations#add 的源代码 (2009/01/17)

