icon 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 语句的片段将在 findcount 方法中被使用。

请先看开头部分。

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)