rubyhfpp2012发表于*编辑于

1. 介绍

假如我们有一个博客网站,有很多文章,用户在查看一篇文章后,在文章尾部可以查看前一篇和后一篇文章。这种需求很容易完成,一般来说,id比当前文章的id大的最近的一条记录就是后一篇文章,只需要一条where("id > ?")语句就可以解决。

但有时候没有这么简单。比如,用户在文章列表通过搜索或排序之后,进入文章,它的下一篇也要按照之前文章列表排序后的顺序出现。也就是用id来比较是无效的。

order_query就是来解决这一问题的,它会按照自己写好的条件来取出结果,再来排序。排好后,把你的记录传过去,就可以返回这条记录前的所有记录和后面的所有录,还有位置等信息,它就是提供了比较好的接口,让你的代码更加简洁。特别适合于瀑布流的应用。

2. 安装

添加下面一行到Gemfile文件。

gem 'order_query', '~> 0.3.2'

执行bundle

3. 使用

以本站为例,所有的博客文章是存在articles表中的,visit_count是article的浏览量,现在我们要按照visit_count来排序。

class Article < ActiveRecord::Base
  include OrderQuery
  order_query :order_title,
    [:visit_count, :desc],
    [:id, :desc]
end

order_query方法有点类似于scope,第一个是定义排序方法的名称,第二个是排序的数组,先按visit_count从大到小排,再按id从大到小来排。

进入rails console进行测试。

首先我们先来看看通过order_title排序后的数据是如何的。

Article.order_title.map {|article| "#{article.title} => {visit_count: #{article.visit_count}}, {id: #{article.id}}"} 

排序产生的sql语句是这样的。

SELECT "articles".* FROM "articles"  ORDER BY "articles"."visit_count" DESC, "articles"."id" DESC

结果记录是下面这样。

[
    [ 0] "登录认证系统的进阶使用 => {visit_count: 124}, {id: 4}",
    [ 1] "devise简单入门教程 => {visit_count: 123}, {id: 3}",
    [ 2] "在阿里云ubuntu主机上安装ruby on rails部署环境 => {visit_count: 88}, {id: 5}",
    [ 3] "使用Monit来监控服务 => {visit_count: 62}, {id: 11}",
    [ 4] "使用mina来部署ruby on rails应用 => {visit_count: 62}, {id: 7}",
    [ 5] "用OneAPM作为你的监控平台   => {visit_count: 56}, {id: 8}",
    [ 6] "升级centos系统上的nginx => {visit_count: 44}, {id: 6}",
    [ 7] "用exception_notification结合Slack或数据库来捕获异常 => {visit_count: 42}, {id: 13}",
    [ 8] "Mina的进阶使用 => {visit_count: 35}, {id: 12}",
    [ 9] "用logrotate切割Ruby on rails日志 => {visit_count: 34}, {id: 10}",
    [10] "使用backup来备份数据库 => {visit_count: 31}, {id: 9}"
]

下面我们以一个实例来说明order_query的用法。我们找上面的第六条记录,也即id等于8的那条"用OneAPM作为你的监控平台",我们来找出它的上一条记录。

article = Article.find 8
Article.order_title_at(article).previous.title

使用的sql语句为:

SELECT  "articles".* FROM "articles" WHERE ("articles"."visit_count" >= 56 AND ("articles"."visit_count" > 56 OR "articles"."visit_count" = 56 AND "articles"."id" > 8))  ORDER BY "articles"."visit_count" ASC, "articles"."id" ASC LIMIT 1

结果正是我们期望的。

"使用mina来部署ruby on rails应用"

假如要返回article前面的所有记录,只需要使用'before'就好了,比如:

Article.order_title_at(article).before

order_query还支持动态的列查找,也就是说,未必要在model文件里事先定义,可以这样。

Article.seek([:visit_count, :desc])

关于其他更多的用法,只要查看官方readme文档就好。

4. 源码解析

接下来是分析order_query的源码实现,我们从model中类方法order_query入手。

# https://github.com/glebm/order_query/blob/master/lib/order_query.rb#L61
def order_query(name, *spec)
  define_singleton_method(:"#{name}_space") { seek(*spec) }
  class_eval <<-RUBY, __FILE__, __LINE__
    scope :#{name}, -> { #{name}_space.scope }
    scope :#{name}_reverse, -> { #{name}_space.scope_reverse }
    def self.#{name}_at(record)
       #{name}_space.at(record)
    end
    def #{name}(scope = self.class)
      scope.#{name}_space.at(self)
    end
  RUBY
end

order_query的源码很简单,只是定义了几个方法。先来看这个scope :#{name}, -> { #{name}_space.scope }#{name}_space这个方法是这行define_singleton_method(:"#{name}_space") { seek(*spec) }定义的,其实最终就是seek(*spec)这个方法,只要能理解seek这个方法,就等于破解了整个order_query方法。

# https://github.com/glebm/order_query/blob/master/lib/order_query.rb#L27
def seek(*spec)
  # allow passing without a splat, as we can easily distinguish
  spec = spec.first if spec.length == 1 && spec.first.first.is_a?(Array)
  Space.new(all, spec)
end

主要是Space.new(all, spec)这个方法。

# https://github.com/glebm/order_query/blob/master/lib/order_query/space.rb#L12
def initialize(base_scope, order_spec)
  @base_scope   = base_scope
  @columns   = order_spec.map { |cond_spec| Column.new(cond_spec, base_scope) }
  # add primary key if columns are not unique
  unless @columns.last.unique?
    raise ArgumentError.new('Unique column must be last') if @columns.detect(&:unique?)
    @columns << Column.new([base_scope.primary_key], base_scope)
  end
  @order_by_sql = SQL::OrderBy.new(@columns)
end

会把那排序的列,比如[:visit_count, :desc], [:id, :desc]给传到@columns = order_spec.map { |cond_spec| Column.new(cond_spec, base_scope) }这行来,处理完之后把结果作为参数传给@order_by_sql = SQL::OrderBy.new(@columns)这一行,最后存到@order_by_sql变量中。

我们先来看一个例子Article.seek([:visit_count, :desc]).first返回的是排序后的第一个元素。

# https://github.com/glebm/order_query/blob/master/lib/order_query/space.rb#L29
def scope
  @scope ||= @base_scope.order(@order_by_sql.build)
end

# https://github.com/glebm/order_query/blob/master/lib/order_query/space.rb#L39
# @return [ActiveRecord::Base]
def first
  scope.first
end

看到这一部分@order_by_sql.build就是利用了上面的结果。那就来看@order_by_sql.build到底做了什么。

#  https://github.com/glebm/order_query/blob/master/lib/order_query/sql/order_by.rb#L3
class OrderBy
  # @param [Array<Column>]
  def initialize(columns)
    @columns = columns
  end

  # @return [String]
  def build
    @sql ||= join_order_by_clauses order_by_sql_clauses
  end

  # @return [String]
  def build_reverse
    @reverse_sql ||= join_order_by_clauses order_by_sql_clauses(true)
  end

  protected

  # @return [Array<String>]
  def order_by_sql_clauses(reverse = false)
    @columns.map { |col| column_clause col, reverse }
  end

  def column_clause(col, reverse = false)
    if col.order_enum
      column_clause_enum col, reverse
    else
      column_clause_ray col, reverse
    end
  end

  def column_clause_ray(col, reverse = false)
    "#{col.column_name} #{sort_direction_sql(col, reverse)}".freeze
  end

  def column_clause_enum(col, reverse = false)
    enum = col.order_enum
    # Collapse boolean enum to `ORDER BY column ASC|DESC`
    if enum == [false, true] || enum == [true, false]
      return column_clause_ray col, reverse ^ enum.last
    end
    enum.map { |v|
      "#{order_by_value_sql col, v} #{sort_direction_sql(col, reverse)}"
    }.join(', ').freeze
  end

  def order_by_value_sql(col, v)
    "#{col.column_name}=#{col.quote v}"
  end

  # @return [String]
  def sort_direction_sql(col, reverse = false)
    col.direction(reverse).to_s.upcase.freeze
  end

  # @param [Array<String>] clauses
  def join_order_by_clauses(clauses)
    clauses.join(', ').freeze
  end
end

只要看仔细每个方法,就会发现最终还是生成了排序用的sql语句,比如"visit_count DESC, id DESC",这个可以传给order方法。那Article.seek([:visit_count, :desc]).first就好理解了,自然跟Article.order("visit_count DESC, id DESC").first一样的。

完结。