有没有一种简单的方法来检查具有完全相同关联的现有 ActiveRecord 对象?

Is there an easy way to check for an existing ActiveRecord object with exactly the same associations?

我正在尝试创建一组代表一组产品的模型。当我创建一个新的包时,我想检查匹配的包是否已经存在,以便我可以重新使用它。

型号

class Bundle < ApplicationRecord
  has_and_belongs_to_many :products
end
class Product < ApplicationRecord
  has_and_belongs_to_many :bundles
end

迁移

class CreateBundles < ActiveRecord::Migration[5.2]
  def change
    create_table :bundles
    create_table :products
    create_join_table :bundles, :products
    add_index :bundles_products, [:bundle_id, :product_id], unique: true
  end
end

我尝试过的

首先我尝试使用 find_or_create_by 但它不喜欢 HABTM 协会:

Bundle.find_or_create_by!(products: Product.where(id: [1, 2, 3]))
# ActiveRecord::UnknownPrimaryKey (Unknown primary key for table bundles_products in model Bundles::HABTM_Products.)

基于其他一些问题,我设法创建了一些方法来实现我所追求的行为。这行得通,但考虑到它看起来有多可怕,这肯定是错误的路径:

class Bundle < ApplicationRecord
  has_and_belongs_to_many :products

  def self.containing_exactly(products)
    product_ids = products.pluck(:id)
    where('bundles.id = (
      SELECT bundles_products.bundle_id
      FROM bundles_products 
      JOIN products on bundles_products.product_id = products.id
      GROUP BY bundles_products.bundle_id
      HAVING COUNT(DISTINCT CASE WHEN products.id IN (?) THEN products.id END) = ? 
      AND COUNT(CASE WHEN products.id NOT IN (?) THEN 1 END) = 0
      LIMIT 1)', product_ids, product_ids.size, product_ids).first
    end
  
  def self.create_for(products)
    containing_exactly(products) || create!(products: products)
  end
end
Bundle.create_for(Product.first(2)) # Creates a new bundle with the first two products
Bundle.create_for(Product.first(2)) # Returns the previous bundle since it has the exact same products

是否有更简单的方法来查找与特定产品集匹配的现有捆绑包,或者是否有其他方法来处理这些模型?

对数据库使用mysql

Rails 中没有 built-in 方法,您必须自己编写查询。你已经差不多明白了,我只建议清理它:

where(<<~SQL, product_ids).first
  bundles.id = (
    select bundle_id
    from bundles_products
    group by bundle_id
    having count(product_id) = count(case when product_id in (?) then 1 else 0 end)
    limit 1
  )
SQL

P. S. 如果这成为性能瓶颈,您总是可以将缓存列添加到“捆绑包”table。但您需要确保始终填写它。

# Migration
add_column :bundles, :products_hash, :string

# Model
def self.create_for(products)
  products_hash = Digest::SHA1.base64digest(products.map(&:id).join(","))
  Bundle.find_or_create_by!(products_hash: products_hash) do
    bundle.products = products
  end
end

你可以做到这一点。

BundlesProducts.where(product_id: product_ids)
  .group_by(&:bundle_id)
  .select { |k, v| v.map(&:product_id) == product_ids}.keys

但这需要定义 BundlesProducts 模型,这里的问题也是我对检索到的记录进行内存处理。