重构复杂查询(续)——OrderQuery类的使用说明
上一次分享中,讲到Arel重构复杂查询时,拿订单查询类中的查询封装举了例子。
class OrderQuery
SALE_ORDER = Order.enum_sale_kinds.values_at(:subs, :spot, :groupon)
SALED_STATUSES = Order.enum_statuses.values_at(:pending, :printing, :arranging, :shipping, :finished, :ready)
REFUNDED_STATUS = Order.enum_statuses[:refunded]
def initialize()
@order = Order.arel_table
end
# 财务视为已售出状态的订单
def saled(include_refunded=true)
_statuses = include_refunded ? SALED_STATUSES << REFUNDED_STATUS : SALED_STATUSES
@order[:sale_kind].in(SALE_ORDER).and(@order[:status].in(_statuses))
end
# 财务视为已退款状态的订单
def refunded
@order[:sale_kind].in(SALE_ORDER).and(@order[:status].eq(REFUNDED_STATUS))
end
# 由指定终端销售的订单
# @param depots 需要查询的终端,可为数组或整数
def saled_by(depots)
return '' if depots.blank?
depots.is_a?(Array) ? @order[:source_id].in(depots).or(@order[:proxy_id].in(depots)) : @order[:source_id].eq(depots).or(@order[:proxy_id].eq(depots))
end
#... other query methods ...
end
当时所举的使用订单查询类替换通常查询的例子:
通常查询
saled_orders = Order.where('proxy_id = ? or source_id = ?', params[:id], params[:id])
.where('created_at between ? and ?', params[:started], params[:ended])
.where(depot_id: params[:depot_id])
使用了订单查询类之后……
q = OrderQuery.new
saled_orders = Order.where(q.saled)
.where(q.time_range(:created_at, params[:started], params[:ended]))
.where(q.saled_by(params[:depot_id]))
不过,考虑到这个查询实际上是可以用ransack进行处理的:
saled_orders = Order.ransack({
proxy_id_or_source_id_eq: params[:id],
created_at_gt: params[:started]
created_at_lt: params[:ended]
depot_id_in: params[:depot_id]
}).result
所以Arel的优势体现的不是很明显。直到上周,伟大的财务要求进行全部已审核完毕的退货单的统计,我感觉机会来了XD。
先简单说下退货单。仓库的退货单分为两种:
- 因临期等原因需要返还供应商进行调换的,此类视为供应商经由采购向仓库要货,财务扮演监察者的角色
- 部分商品因破损或者丢失而无法平仓的,仓库提交退货申请后,经由财务进行审核通过后,会变为盘亏订单
如果用arel直接构造条件,查询退货单,那么可能会诞生出类似下面的方法:
def wareh_returned
# 条件2
@order[:sale_kind].eq(Order.enum_sale_kinds[:deficit]).and(@order[:status].eq(Order.enum_statuses[:finished]))
# 条件1
.or(@order[:sale_kind].eq(Order.enum_sale_kinds[:purchase_refunded]).and(@order[:status].eq(Order.enum_statuses[:returned])))
end
写完之后,如果仔细观察这一新增的方法,你会发现,他的结构和之前的方法很像,都是查询”status”和”sale_kind”在范围内的某些订单,唯一不同是将两种单子用or进行了拼接。如果之后还有类似的查询接口要暴露出去,那么又会增加类似方法,稍微复杂一些的就会生成类似于我们新写的这个和saled_by方法中的代码。这违反了DRY原则,也增加了代码维护的难度(试想你在一堆括号中判断逻辑关系的情景……)
解决这一尴尬问题的途径就是将类进行DSL化重构。元编程配合arel的强大,可以产生出你自己的一套Sql DSL。
重构后的OrderQuery类似下面这样:
class OrderQuery
SALE_ORDER = [:subs, :spot, :groupon]
SALED_STATUSES = [:pending, :printing, :arranging, :shipping, :finished, :ready]
REFUNDED_STATUS = :refunded
def initialize()
@order = Order.arel_table
end
# 财务视为已售出状态的订单
def saled(include_refunded=true)
_statuses = include_refunded ? SALED_STATUSES << REFUNDED_STATUS : SALED_STATUSES
build_condition(:and, {sale_kind: SALE_ORDER, status: _statuses})
end
# 财务视为已退款状态的订单
def refunded
build_condition(:and, {sale_kind: SALE_ORDER, status: [REFUNDED_STATUS]})
end
# 财务视为已审核状态的仓库退货单
# 仓库的退货单分为两种:
# 1、因临期等原因需要返还供应商进行调换的,此类视为供应商经由采购向仓库要货,财务扮演监察者的角色
# 2、部分商品因破损或者丢失而无法平仓的,仓库提交退货申请后,经由财务进行审核通过后,会变为盘亏订单
def wareh_returned
build_condition(:and, {sale_kind: [:deficit], status: [:finished]}).or(build_condition(:and, {sale_kind: [:purchase_refunded], status: [:returned]}))
end
# 由指定终端销售的订单
# @param depots 需要查询的终端,可为数组或整数
def saled_by(depots)
return '' if depots.blank?
depots = Array(depots)
build_condition(:or, {source_id: depots, proxy_id: depots})
end
# 某date型字段一定时间范围内的订单
# @param [Symblo] column 要查询的字段名
# @param [String] started 要查询的起始时间,支持多种格式,如:'2014-05-26' 或 '2014/05/26'
# @param [String] ended 要查询的结束时间,例如:'2014-05-27'
# @param [Boolean] format_ended 是否自动格式化结束时间到当日结束
# @return [ActiveRecord::Relation]
# @example 查询退款时间为14年5月24日到14年5月26日的订单
# query = OrderQuery.new
# Order.where(query.time_range(:refunded_at, '2014-05-24', '2014-05-26') #=> [#<Order>]
def time_range(column, started, ended=nil, format_ended: true)
_started = started.blank? ? Time.now.beginning_of_day : Time.parse(started)
_ended = if ended.blank?
_started.end_of_day
else
format_ended ? Time.parse(ended).end_of_day : Time.parse(ended)
end
@order[column].gteq(_started).and(@order[column].lteq(_ended))
end
private
# 根据传入的条件哈希组构建arel节点并返回
# @param [Symblo] operator 操作符,or/and
# @param [Hash] args 要查询的字段为索引的符号值集合,详见下面示例
# @return [ActiveRecord::Relation]
# @todo 更强的参数形式支持
# @example 查询门店id为425的已取消或者已退款状态的前10条预订单
# query = OrderQuery.new
# Order.where(order.build_condition(:and, {sale_kind:[:subs],status:[:cancel,:refunded],proxy_id:[425]})).limit(10)
# #=> SELECT `orders`.* FROM `orders` WHERE (`orders`.`sale_kind` IN (0) AND `orders`.`status` IN (6, 7) AND `orders`.`proxy_id` IN (425)) LIMIT 10
def build_condition(operator, *args)
args.extract_options!.inject(nil) { |result, (key, val)|
# 将enum值转化为对应数值
val = symtoval(key, *val) if Order.respond_to?("enum_#{key.to_s}".pluralize.to_sym)
if result.nil?
result = val.length == 1 ? @order[key].eq(val.take(1)) : @order[key].in(val)
else
result = result.send(operator, val.length == 1 ? @order[key].eq(val.take(1)) : @order[key].in(val) )
end
}
end
# 将订单类某字段的符号值转为对应的数值
# @param [Symblo] column 要查询的字段名
# @param [Symblo] symblo_array 所需查询字段的enum符号数组
# @return [Array] 对应符号的数值组
# @example 查询status字段,:finished和:pending状态对应的值
# symtoval(:status, :finished, :pending) #=> [5,0]
def symtoval(column, *symblos)
Order.send("enum_#{column.to_s.pluralize}").values_at(*symblos).uniq.compact
end
end
通过增加build_condition这一方法,很好地达成了DRY原则。也为之后可能出现的类似方法提供了很好的抽象支持,如果暴露为外部接口,则能大大简化Sql查询。