Gem 使用 associationist 玩转 Rails 虚拟关联

cichol · 2019年05月26日 · 最后由 cicholgricenchos 回复于 2020年03月07日 · 7454 次阅读
本帖已被设为精华帖!

Github Repo 中文文档

一般来说 Rails 的关联是要在数据表里通过外键实现的,但是有时候会有一些形式上是关联的数据,却没法通过数据表实现。

例如我们想在一个保存为 text 字段的帖子中,查询所有提到的人,并且使用 includes 一并加载出来,也就是我们想:

Post.first.mentioned_people # => 所有被提到的Person
Post.includes(:mentioned_people).all

往常我们是做不到的,因为 mentioned_people 和 post 之间并没有真正存在的关联。这时候我们可以用 associationist 虚拟出一个自定义的关联,具体代码是这样的:

class Post < ApplicationRecord
  include Associationist::Mixin.new(
    name: :mentioned_people,
    type: :collection,
    scope: -> post {
      Person.where(id: post.extract_mentioned_ids)
    }
  )

  def extract_mentioned_ids # 返回一个id数组
    content.scan(/提取id的模式/).map(&:first).map(&:to_i)
  end
end

现在直接在 post 对象上调用已经可以正确取出关联了,并且可以像往常一样在之后叠加 limit, count 等方法。

Post.first.mentioned_people
Post.first.mentioned_people.limit(1)
Post.first.mentioned_people.count

不过只定义了 scope 的情况,在 preload 的时候还是会有 n+1 问题,因为 active record 的 preloader 还不知道怎么批量加载这些关联,需要我们自己定义:

class Post < ApplicationRecord
  include Associationist::Mixin.new(
    name: :mentioned_people,
    type: :collection,
    scope: -> post {
      Person.where(id: post.extract_mentioned_ids)
    },
    # preloader需要返回一个key为对象,value为关联数据的hash
    preloader: -> posts {
      people_ids = posts.map(&:extract_mentioned_ids).inject(:+)
      people_hash = Person.where(id: people_ids).map{|person| [person.id, person]}.to_h
      posts.map do |post|
        [post, post.extract_mentioned_ids.map{|id| people_hash[id]}]
      end.to_h
    }
  )
end

这个 preloader 做的事就是将一组 post 关联的 people id 先取出来,然后使用一次 sql 查询查出,再安装回 post 上。

现在 includes 方法也可以正常使用了,并且可以像往常一样添加多级的 includes:

Post.includes(:mentioned_people).all
Post.includes(mentioned_people: :address).all

事实上可以定义成虚拟关联的不只 scope,可以是任何对象:

class Product < ApplicationRecord
  include Associationist::Mixin.new(
    name: :stock,
    preloader: -> products {
      products.map{|product| [product, 1]}.to_h
    }
  )
end

Product.first.stock #=> 1

这样我们可以把一些复杂 sql 甚至不是 sql 的东西抽象成关联。事实上我设计 associationist 的初衷,就是想让一个很复杂的库存查询的取用变简单。

目前 associationist 只提供了最基础的虚拟关联,其实还可以更进一步去提供一些操作 collection proxy 的方法等等,这么做可以让 active record 真正变成多数据源的,不仅仅能用 sql。(不过没需求就算了)

顺便一提,我之前做的用来实现 shopify 的智能类目的 gem:https://ruby-china.org/topics/34865, 已经改为基于 associationist 实现。 smart_collection 里面的 scope 是不定的,对于每个条目都可能生成不同的 scope,所以 preloader 没有这个 Post 的这么好写,要额外用到一个 cache store 或者 cache table,有需要的可以参考参考。

jasl 将本帖设为了精华贴 05月26日 17:59

有意思

水桥还是有意思 。你说的 Preloader 其实就是用 in 查询解决。记得加 Index 不然巨慢

so_zengtao 回复

是的

你泯灭了了的

这个完善一下如果能集成到 rails 里,对复杂项目还是有很大帮助的,比一些语法糖之类的 feature 要有用的多

hooopo 回复

嗯,在打理一下可以发个 feature request 看看他们喜不喜欢

Association 的 Preloader 挺复杂的,虚拟关联也是刚需,之前我尝试实现虚拟关联(用 Association 来实现虚拟关联的),但是工作量太大就放弃了

例如我们想在一个保存为 text 字段的帖子中,查询所有提到的人,并且使用 includes 一并加载出来,也就是我们想:... 往常我们是做不到的,因为 mentioned_people 和 post 之间并没有真正存在的关联。

所以为什么不创建关联呢?

你好,你的这个例子是否还需要在 Person 表中加一个 column: post_id? 不然的话在创建 post 的时候会出现 ActiveModel::MissingAttributeError: can't write unknown attribute 'post_id'报错,因为这个 gem 给 Post 加了 Person 的 assoication.

fidel 回复

回复晚了不好意思,添加的关联是 post has_many people,没有定义反向关联,应该是不需要你说的 post_id 的,或者可以试试升级一下 gem 的版本再试试,如果有问题可以在 github 上发个 issue 具体看看

虚拟关联不能 eager_load, 因为 eager load 会用 join, 导致报错提示 MentionedPeople 常量没有定义, 改成 preload 就行

fangxing204 回复

确实不行,应该提示一下的,我建个 issue 先

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册