F's Blog

博客 收藏夹
Rails Active Record

28 Nov 2016

组成

ActiveModel 是 ActiveRecord 的一部分,主要负责模型相关,而后者主要负责数据库等其它工作。

关系

主要参数

其中 dependent 是需要好好仔细考虑的,弄不好可能会有循环,把所有内容给删除掉。

has_many through

多对多的关系。

polymorphic

主要解决一张表要记录多种类型的数据。这样方便筛选。

比如一张评论表,想记录照片的评论,又想记录文章的评论,那么就用polymorphic吧,而不是建两张表。

polymorphic 翻译来就是“多态”,它是面向对象的基石。 它使程序能够把不同种类的东西当作相同的东西来处理,从而做到更高层的抽象。

所以它还能实现继承, 也就是单表继承让所有子类在一张表里。

只不过继承里 xxxxable 是父类(就不叫 xxxxable 了),而 xxxxable 是组合关系,而且是被多个类拥有,比如 文章和照片都可以有评论。其实都可以理解为一对多的关系,被多个子类继承,或被多个类拥有。在设计模式里,更倾向于 使用组合,因为这样更耦合。

只有理解了各个类之间的关系,比如是继承还是耦合,才能更好的给类起名字。一个好的类名说明你对它们的 关系理解了。

实现上Java用接口,Ruby用了Duck Type。

那么在表数据中,如何来表示这条记录是哪个态呢?多加一个type字段,一般是xxxxable_type.

那如果这条数据和其它数据关联,如何记录?多加一个那个关联数据的id,一般是xxxxable_id.

这就是Rails里model里的polymorphic的功能。

具体例子参考RailsCasts。在这个例子中comment可以是Article、Photo和Event的comment。

它们model如下:

class Comment < ActiveRecord::Base
  belongs_to :commentable, :polymorphic => true
end

class Article < ActiveRecord::Base
  has_many :comments, :as => :commentable
end

class Photo < ActiveRecord::Base
  has_many :comments, :as => :commentable
  #...
end

class Event < ActiveRecord::Base
  has_many :comments, :as => :commentable
end

在数据库migration里,可以用 t.references 来创建表结构:

class CreatePictures < ActiveRecord::Migration[5.0]
  def change
    create_table :comments do |t|
      t.string :content
      t.references :commentable, polymorphic: true, index: true
      t.timestamps
    end
  end
end

比如 a_phote.comments 将会进行如下查询:

 SELECT "comments".* FROM "comments" WHERE "comments"."commentable_id" = $1 AND "comments"."commentable_type" = $2  [["commentable_id", 1], ["commentable_type", "Phote"]]

上面的 t.references :commentable, polymorphic: true, index: true 已经把 commentable_id 和 commentable_type 一起加入到数据框的索引了,相当于在 migration里:

add_index :comments, [:commentable_id, :commentable_type]

使用belongs_to和has_many这么“通俗”的方法就搞定了。可以看到不像非多态时,belongs_to 后的 参数是具体的 Owner,比如 user,这里直接是 commentable,并且 polymorphic 为 true。 正式这样将其多态了,因为没有指明具体的类,而是一个需要有评论功能的类,比如 Photo。当然,也就不用 belongs_to 给每个可能的 Owner 了,都在 commentable 里了。

而这个 Photo 类通过 has_many(拥有) comments,并且 as(当做)commentable 来变成可以被评论的。 然后这个 commentable 可以通过 comments 表里的 commentable_id 和 commentable_type 来定位。如:

comment.commentable # 将得到一个实例,比如 photo

创建评论的话,用:

# has_many
phote.comments.create

# has_one 的情况
phote.create_comment

一次 belongs_to,多次被 has_many. 值!

这里也可以理解为Comment通过:

belongs_to :commentable, :polymorphic => true

创建了commentable父类,下面的Article、Phote和Event里评论就是commentable的子类:

ArticleComment、PhoteComment和EventComment,只不过它们都存在comments这张表里,靠表里的 commentable_type属性来区分,同时通过commentable_id来记录是哪个article、phote或event的 comment。如果是article的,那么commentable_type就是”atricle”,commentable_id就是article 的id。

comments 的 controller 如下:

def index
  @commentable = find_commentable
  @comments = @commentable.comments
end

def create
  @commentable = find_commentable
  @comment = @commentable.comments.build(params[:comment])
  if @comment.save
    flash[:notice] = "Successfully created comment."
    redirect_to :id => nil
  else
    render :action => 'new'
  end
end

private

def find_commentable
  params.each do |name, value|  # 查找表单里的内容
    if name =~ /(.+)_id$/ # 比如是,photo_id
      return $1.classify.constantize.find(value) # value 就是phote的id
    end
  end
  nil
end

这里的 constantize 方法能获得字符串的常量,即在变成环境里能用的变量。

Mixin —— 混入

其实上面的多态,可以理解为一种混入方式。只不过这次需要存入数据库里。

在 Rails 里,一般的混入是在 models/concerns 里定义的 module,并 extend ActiveSupport::Concern, 后者让 module 更规范化,更容易被 extended。

对,混入有一个好记的特征 —— 名字以 able 结尾。表示有了它就有了某种能力。

混入是一种多父类继承的一种实现方式,对于类里共同的功能进行抽象。

Validate 数据验证

Validator

rails/active_model/lib/active_model/validator.rb

验证器的父类,每个验证器需要继承 ActiveModel::EachValidator,实现里面的

def validate_each(record, attribute, value)
  raise NotImplementedError, "Subclasses must implement a validate_each(record, attribute, value) method"
end

# 调用 validate_each 进行验证.
def validate(record)
  attributes.each do |attribute|
    value = record.read_attribute_for_validation(attribute)
    next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank])
    # 回调
    validate_each(record, attribute, value)
  end
end

自带的放在 validations 目录里。

比如最简单的 validations/presence.rb

module ActiveModel
  module Validations
    class PresenceValidator < EachValidator
      def validate_each(record, attr_name, value)
        record.errors.add(attr_name, :blank, options) if value.blank?
      end
    end

    module HelperMethods
      def validates_presence_of(*attr_names)
        validates_with PresenceValidator, _merge_attributes(attr_names)
      end
    end
  end
end

而我们在使用时一般都是:

validates :name, presence: true

这是为什么呢?因为在 validates/validates.rb 里定义了:

module ActiveModel
  module Validations
    module ClassMethods
      def validates(*attributes)
        defaults = attributes.extract_options!.dup
        # 取出 validates hash
        validations = defaults.slice!(*_validates_default_keys)

        raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
        raise ArgumentError, "You need to supply at least one validation" if validations.empty?

        defaults[:attributes] = attributes

        validations.each do |key, options|
          next unless options
          # 根据 validate 的 key,定位 validator
          key = "#{key.to_s.camelize}Validator"

          begin
            validator = key.include?("::".freeze) ? key.constantize : const_get(key)
          rescue NameError
            raise ArgumentError, "Unknown validator: '#{key}'"
          end

          # 验证
          validates_with(validator, defaults.merge(_parse_validates_options(options)))
        end
      end
      # ...
    end
  end
end

提一下,这里的 _parse_validates_options 解释了为什么不用 :in 就可以传 range:

def _parse_validates_options(options)
  case options
  when TrueClass
    {}
  when Hash
    options
  when Range, Array
    { in: options }
  else
    { with: options }
  end
end

而在 validations/with.rb 里:

module ActiveModel
  module Validations
    module ClassMethods
      def validates_with(*args, &block)
        options = args.extract_options!
        options[:class] = self

        args.each do |klass|
          validator = klass.new(options, &block)

          if validator.respond_to?(:attributes) && !validator.attributes.empty?
            validator.attributes.each do |attribute|
              _validators[attribute.to_sym] << validator
            end
          else
            _validators[nil] << validator
          end

          validate(validator, options)
        end
      end
    end
  end
end

所有抓住了 validates/validates.rb 文件,就得到了 validate 的核心。

ActiveRecord 里也有 validates.rb,进行比如 uniqueness 等的验证。

Client Side validations

能够映射 model 的 validates,在 view 里生成 js 来提前验证输入。

Query

查询返回的结果是 ActiveRecord::Relation,它有很多方法,比如 maximum,joins 等等。

如何从 Array 到 Relation 呢?只能再来一次查询了:

User.where(id: posts.map(&:user_id))

joins

Eager Loading

includes

ORM 的 N + 1 问题: 本来可以 2 次查完的,却查了 N + 1 次。

比如在显示 Post 时附带着显示评论。

@posts = Post.all

<% @posts.each do |post| %>
  <tr>
    <td><%= post.name %></td>
    <td><%= post.comments.map(&:name) %></td>
  </tr>
<% end %>

那么就会生成如下的 SQL:

Post Load (1.0ms)   SELECT * FROM "posts"
Comment Load (0.4ms)   SELECT * FROM "comments" WHERE ("comments".post_id = 1)
Comment Load (0.3ms)   SELECT * FROM "comments" WHERE ("comments".post_id = 2)

解决就用 includes:

@posts = Post.includes(:comments)

那么 SQL 就变成了两句了:

Post Load (0.5ms)   SELECT * FROM "posts"
Comment Load (0.5ms)   SELECT "comments".* FROM "comments" WHERE ("comments".post_id IN (1,2))

提前加载完后,至于如何配对,那就是 Rails 的方案了,应该是 SQL 缓冲技术。

goldiloader gem

不喜欢重复的程序员,用goldiloader gem 可以方便的解决上面的问题。

默认它会自动提前加载所有相关的数据,如果不需要,可以在 has_many 后面指明 auto_include: false。

or

Post.where("id = 1").or(Post.where("author_id = 3"))
# SELECT `posts`.* FROM `posts`  WHERE (('id = 1' OR 'author_id = 3'))

merge

Merges in the conditions from other, if other is an ActiveRecord::Relation. Returns an array representing the intersection of the resulting records with other, if other is an array.

Post.where(published: true).joins(:comments).merge( Comment.where(spam: false) )

Performs a single join query with both where conditions.

recent_posts = Post.order(‘created_at DESC’).first(5) Post.where(published: true).merge(recent_posts)

Returns the intersection of all published posts with the 5 most recently created posts.

(This is just an example. You’d probably want to do this with a single query!)

Procs will be evaluated by merge:

Post.where(published: true).merge(-> { joins(:comments) })

=> Post.where(published: true).joins(:comments)

This is mainly intended for sharing common conditions between multiple associations.

find_each

The find_each method retrieves a batch of records and then yields each record to the block individually as a model. In the following example, find_each will retrieve 1000 records (the current default for both find_each and find_in_batches) and then yield each record individually to the block as a model. This process is repeated until all of the records have been processed:

User.find_each do |user|
 NewsMailer.weekly(user).deliver_now
end

find_in_batches

The find_in_batches method is similar to find_each, since both retrieve batches of records. The difference is that find_in_batches yields batches to the block as an array of models, instead of individually. The following example will yield to the supplied block an array of up to 1000 invoices at a time, with the final block containing any remaining invoices:

# Give add_invoices an array of 1000 invoices at a time
Invoice.find_in_batches do |invoices|
  export.add_invoices(invoices)
end

Active Record Callbacks

其它

serialize

serialize(attr_name, class_name = Object) public

If you have an attribute that needs to be saved to the database as an object, and retrieved as the same object, then specify the name of that attribute using this method and it will be handled automatically. The serialization is done through YAML. If class_name is specified, the serialized object must be of that class on retrieval or SerializationTypeMismatch will be raised.

参考

本文由 付豪 创作,采用署名 4.0 国际(CC BY 4.0)创作共享协议进行许可,详细声明