在具有唯一约束的关联中使用 Rails 中的 factory_girl。得到重复的错误

Posted

技术标签:

【中文标题】在具有唯一约束的关联中使用 Rails 中的 factory_girl。得到重复的错误【英文标题】:Using factory_girl in Rails with associations that have unique constraints. Getting duplicate errors 【发布时间】:2011-01-02 04:42:00 【问题描述】:

我正在使用 Rails 2.2 项目来更新它。我正在用工厂替换现有的固定装置(使用 factory_girl)并且遇到了一些问题。问题在于表示具有查找数据的表的模型。当我使用两种产品类型相同的产品创建购物车时,每个创建的产品都在重新创建相同的产品类型。此错误来自对 ProductType 模型的唯一验证。

问题演示

这是来自一个单元测试,我在其中创建了一个购物车并将其拼凑在一起。我必须这样做才能解决问题。不过,这仍然说明了问题。我会解释的。

cart = Factory(:cart)
cart.cart_items = [Factory(:cart_item, 
                           :cart => cart, 
                           :product => Factory(:added_users_product)),
                   Factory(:cart_item, 
                           :cart => cart, 
                           :product => Factory(:added_profiles_product))]

添加的两个产品属于同一类型,创建每个产品时都会重新创建产品类型并创建副本。

生成的错误是: "ActiveRecord::RecordInvalid: 验证失败:名称已被占用,代码已被占用"​​p>

解决方法

此示例的解决方法是覆盖正在使用的产品类型并传入特定实例,因此仅使用一个实例。 “add_product_type”被提前获取并为每个购物车项目传入。

cart = Factory(:cart)
prod_type = Factory(:add_product_type)   #New
cart.cart_items = [Factory(:cart_item,
                           :cart => cart,
                           :product => Factory(:added_users_product,
                                               :product_type => prod_type)), #New
                   Factory(:cart_item,
                           :cart => cart,
                           :product => Factory(:added_profiles_product,
                                               :product_type => prod_type))] #New

问题

将 factory_girl 与“选择列表”类型的关联一起使用的最佳方式是什么?

我希望喜欢工厂定义包含所有内容,而不必在测试中组装它,尽管我可以忍受它。

背景和额外细节

工厂/product.rb

# Declare ProductTypes

Factory.define :product_type do |t|
  t.name "None"
  t.code "none"
end

Factory.define :sub_product_type, :parent => :product_type do |t|
  t.name "Subscription"
  t.code "sub"
end

Factory.define :add_product_type, :parent => :product_type do |t|
  t.name "Additions"
  t.code "add"
end

# Declare Products

Factory.define :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

Factory.define :added_profiles_product, :parent => :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

Factory.define :added_users_product, :parent => :product do |p|
  p.association :product_type, :factory => :add_product_type
  #...
end

ProductType 的“代码”的目的是让应用程序可以赋予它们特殊的含义。 ProductType 模型如下所示:

class ProductType < ActiveRecord::Base
  has_many :products

  validates_presence_of :name, :code
  validates_uniqueness_of :name, :code
  #...
end

工厂/cart.rb

# Define Cart Items

Factory.define :cart_item do |i|
  i.association :cart
  i.association :product, :factory => :test_product
  i.quantity 1
end

Factory.define :cart_item_sub, :parent => :cart_item do |i|
  i.association :product, :factory => :year_sub_product
end

Factory.define :cart_item_add_profiles, :parent => :cart_item do |i|
  i.association :product, :factory => :add_profiles_product
end

# Define Carts

# Define a basic cart class. No cart_items as it creates dups with lookup types.
Factory.define :cart do |c|
  c.association :account, :factory => :trial_account
end

Factory.define :cart_with_two_different_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item, 
                               :cart => cart, 
                               :product => Factory(:year_sub_product)),
                       Factory(:cart_item, 
                               :cart => cart, 
                               :product => Factory(:added_profiles_product))]
  end
end

当我尝试使用相同产品类型的两个商品定义购物车时,我收到上述相同的错误。

Factory.define :cart_with_two_add_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_users_product)),
                       Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_profiles_product))]
  end
end

【问题讨论】:

【参考方案1】:

仅供参考,您也可以在工厂中使用initialize_with 宏并检查对象是否已经存在,然后不要再次创建它。使用 lambda 的解决方案(它很棒,但是!)正在复制 find_or_create_by 中已经存在的逻辑。这也适用于通过关联工厂创建 :league 的关联。

FactoryGirl.define do
  factory :league, :aliases => [:euro_cup] do
    id 1
    name "European Championship"
    rank 30
    initialize_with  League.find_or_create_by_id(id)
  end
end

【讨论】:

当然,这依赖于 ActiveRecord,但对于大多数人来说这应该不是问题。简单、优雅、灵活……非常好。 另外,这也适用于嵌套工厂和其他属性。很好的解决方案! 很好的解决方案,拯救了我的一天。 不幸的是 find_or_create_by_id 在 Rails 4 中被弃用。并且 find_or_create_by 由于某种原因失败.. @PavelK。而不是使用 League.where( id: 1, name: "European Championship", rank: 30).first_or_create 进行初始化【参考方案2】:

我遇到了同样的问题,并在我的工厂文件顶部添加了一个实现单例模式的 lambda,如果自上一轮测试/规范以来已清除数据库,它也会重新生成模型:

saved_single_instances = 
#Find or create the model instance
single_instances = lambda do |factory_key|
  begin
    saved_single_instances[factory_key].reload
  rescue NoMethodError, ActiveRecord::RecordNotFound  
    #was never created (is nil) or was cleared from db
    saved_single_instances[factory_key] = Factory.create(factory_key)  #recreate
  end

  return saved_single_instances[factory_key]
end

然后,使用您的示例工厂,您可以使用 factory_girl 惰性属性来运行 lambda

Factory.define :product do |p|
  p.product_type   single_instances[:add_product_type] 
  #...this block edited as per comment below
end

瞧!

【讨论】:

优秀的解决方案!感谢分享。它使使用变得简单而优雅。正是我需要的。 :) 谢谢!这也是我一直在寻找的。请注意,您可以省略 lambda 中的“return”行,因为 begin 或 rescue 块已经返回正确的内容。 第二个代码块有一个小错字。它应该说“single_instances”而不是“single_instance”。尽管如此,很好的解决方案。 有没有办法在工厂块之外使用single_instance lambda?我已经考虑过Factory(:product).product_type 的解决方法,但希望有一种更直接 的方式来访问product_type。 非常好的解决方案。似乎这应该内置在Factory Girl中。【参考方案3】:

编辑: 在此答案的底部查看更清洁的解决方案。

原始答案: 这是我创建 FactoryGirl 单例关联的解决方案:

FactoryGirl.define do
  factory :platform do
    name 'Foo'
  end

  factory :platform_version do
    name 'Bar'
    platform 
      if Platform.find(:first).blank?
        FactoryGirl.create(:platform)
      else
        Platform.find(:first)
      end
    
  end
end

你称之为例如喜欢:

And the following platform versions exists:
  | Name     |
  | Master   |
  | Slave    |
  | Replica  |

这样,所有 3 个平台版本都将具有相同的平台“Foo”,即单例。

如果你想保存一个数据库查询,你可以这样做:

platform 
  search = Platform.find(:first)
  if search.blank?
    FactoryGirl.create(:platform)
  else
    search
  end

您可以考虑将单例关联设为特征:

factory :platform_version do
  name 'Bar'
  platform

  trait :singleton do
    platform 
      search = Platform.find(:first)
      if search.blank?
        FactoryGirl.create(:platform)
      else
        search
      end
    
  end

  factory :singleton_platform_version, :traits => [:singleton]
end

如果您想设置超过 1 个平台,并且有不同的平台版本集,您可以制作更具体的不同特征,即:

factory :platform_version do
  name 'Bar'
  platform

  trait :singleton do
    platform 
      search = Platform.find(:first)
      if search.blank?
        FactoryGirl.create(:platform)
      else
        search
      end
    
  end

  trait :newfoo do
    platform 
      search = Platform.find_by_name('NewFoo')
      if search.blank?
        FactoryGirl.create(:platform, :name => 'NewFoo')
      else
        search
      end
    
  end

  factory :singleton_platform_version, :traits => [:singleton]
  factory :newfoo_platform_version, :traits => [:newfoo]
end

希望这对某些人有用。

编辑: 在提交了我上面的原始解决方案后,我又看了一下代码,并找到了一种更简洁的方法:您不在工厂中定义特征,而是在调用测试步骤时指定关联。

制作常规工厂:

FactoryGirl.define do
  factory :platform do
    name 'Foo'
  end

  factory :platform_version do
    name 'Bar'
    platform
  end
end

现在您使用指定的关联调用测试步骤:

And the following platform versions exists:
  | Name     | Platform     |
  | Master   | Name: NewFoo |
  | Slave    | Name: NewFoo |
  | Replica  | Name: NewFoo |

这样做时,平台“NewFoo”的创建使用了“find_or_create_by”功能,所以第一次调用创建平台,接下来的2次调用找到已经创建的平台。

通过这种方式,所有 3 个平台版本都将具有相同的平台“NewFoo”,您可以根据需要创建任意数量的平台版本集。

我认为这是一个非常干净的解决方案,因为您保持工厂清洁,并且您甚至可以让测试步骤的读者看到这 3 个平台版本都具有相同的平台。

【讨论】:

底部的清洁解决方案看起来不错,但不允许控制 NewFoo 的其他属性。例如,如果关联模型是具有“名字”和“姓氏”字段的人怎么办? 没错,它只允许控制一个属性。然而,它解决了我的大部分用例。也许可以重写 teststep 以控制 n 个属性。【参考方案4】:

简短的回答是,“不”,工厂女孩没有更清洁的方法来做到这一点。我似乎在工厂女孩论坛上验证了这一点。

但是,我为自己找到了另一个答案。它涉及另一种解决方法,但可以让一切变得更干净。

我们的想法是更改代表查找表的模型,以便在缺失时创建所需的条目。这没关系,因为代码期望存在特定的条目。这是修改后模型的示例。

class ProductType < ActiveRecord::Base
  has_many :products

  validates_presence_of :name, :code
  validates_uniqueness_of :name, :code

  # Constants defined for the class.
  CODE_FOR_SUBSCRIPTION = "sub"
  CODE_FOR_ADDITION = "add"

  # Get the ID for of the entry that represents a trial account status.
  def self.id_for_subscription
    type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_SUBSCRIPTION])
    # if the type wasn't found, create it.
    if type.nil?
      type = ProductType.create!(:name => 'Subscription', :code => CODE_FOR_SUBSCRIPTION)
    end
    # Return the loaded or created ID
    type.id
  end

  # Get the ID for of the entry that represents a trial account status.
  def self.id_for_addition
    type = ProductType.find(:first, :conditions => ["code = ?", CODE_FOR_ADDITION])
    # if the type wasn't found, create it.
    if type.nil?
      type = ProductType.create!(:name => 'Additions', :code => CODE_FOR_ADDITION)
    end
    # Return the loaded or created ID
    type.id
  end
end

“id_for_addition”的静态类方法如果找到就会加载模型和ID,如果没有找到就会创建它。

缺点是“id_for_addition”方法的名称可能不清楚它的作用。这可能需要改变。对正常使用的唯一其他代码影响是额外测试以查看是否找到模型。

这意味着用于创建产品的工厂代码可以这样更改...

Factory.define :added_users_product, :parent => :product do |p|
  #p.association :product_type, :factory => :add_product_type
  p.product_type_id  ProductType.id_for_addition 
end

这意味着修改后的工厂代码可以如下所示...

Factory.define :cart_with_two_add_items, :parent => :cart do |o|
  o.after_build do |cart|
    cart.cart_items = [Factory(:cart_item_add_users, :cart => cart),
                       Factory(:cart_item_add_profiles, :cart => cart)]
  end
end

这正是我想要的。我现在可以清晰地表达我的工厂和测试代码了。

这种方法的另一个好处是查找表数据不需要在迁移中植入或填充。它将自行处理测试数据库和生产数据库。

【讨论】:

尝试在 factory_girl github.com/thoughtbot/factory_girl/issues987654321@ 的 github 问题中发布此内容 在你建议之前尝试在github.com/thoughtbot/factory_girl/issues搜索“singleton”:(【参考方案5】:

当将单例引入工厂时,这些问题将被消除 - 目前位于 -http://github.com/roderickvd/factory_girl/tree/singletons 问题 - http://github.com/thoughtbot/factory_girl/issues#issue/16

【讨论】:

太棒了!谢谢你告诉我这件事。我会看这个的。 另见github.com/thoughtbot/factory_girl/issues/24 和github.com/thoughtbot/factory_girl/issues/148。 factory_girl 团队显然是在埋头苦干。【参考方案6】:

我也遇到过类似的情况。我最终使用我的seeds.rb 来定义单例,然后要求spec_helper.rb 中的seeds.rb 将对象创建到测试数据库中。然后我就可以在工厂里搜索合适的对象了。

db/seeds.rb

RegionType.find_or_create_by_region_type('community')
RegionType.find_or_create_by_region_type('province')

spec/spec_helper.rb

require "#Rails.root/db/seeds.rb"

spec/factory.rb

FactoryGirl.define do
  factory :region_community, class: Region do
    sequence(:name)  |n| "Community#n" 
    region_type  RegionType.find_by_region_type("community") 
  end
end

【讨论】:

【参考方案7】:

我也遇到过同样的问题,我认为这与此处引用的问题相同:http://groups.google.com/group/factory_girl/browse_frm/thread/68947290d1819952/ef22581f4cd05aa9?tvc=1&q=associations+validates_uniqueness_of#ef22581f4cd05aa9

我认为您的解决方法可能是解决问题的最佳方法。

【讨论】:

【参考方案8】:

受到此处答案的启发,我发现@Jonas Bang 的建议最符合我的需求。以下是 2016 年年中对我有用的方法(FactoryGirl v4.7.0,Rails 5rc1):

FactoryGirl.define do
  factory :platform do
    name 'Foo'
  end

  factory :platform_version do
    name 'Bar'
    platform  Platform.first || create(:platform) 
  end
end

使用它创建四个具有相同平台引用的platform_version的示例:

FactoryGirl.create :platform_version
FactoryGirl.create :platform_version, name: 'Car'
FactoryGirl.create :platform_version, name: 'Dar'

=>

-------------------
 platform_versions
-------------------
 name | platform
------+------------
 Bar  | Foo
 Car  | Foo
 Dar  | Foo

如果您需要在不同平台上使用“Dar”:

FactoryGirl.create :platform_version
FactoryGirl.create :platform_version, name: 'Car'
FactoryGirl.create :platform_version, name: 'Dar', platform: create(:platform, name: 'Goo')

=>

-------------------
 platform_versions
-------------------
 name | platform
------+------------
 Bar  | Foo
 Car  | Foo
 Dar  | Goo

感觉就像两全其美,没有把 factory_girl 弯曲得太远。

【讨论】:

【参考方案9】:

也许您可以尝试使用 factory_girl 的序列作为产品类型名称和代码字段?对于大多数测试,我猜您不会关心产品类型的代码是“code 1”还是“sub”,而对于您关心的那些,您始终可以明确指定。

Factory.sequence(:product_type_name)  |n| "ProductType#n" 
Factory.sequence(:product_type_code)  |n| "prod_#n"         

Factory.define :product_type do |t|
  t.name  Factory.next(:product_type_name) 
  t.code  Factory.next(:product_type_code) 
end 

【讨论】:

感谢您的建议。在某些情况下,这会很好。但是,就我而言,关键是只有一个。代码的用途是由应用程序使用的,因此它可以为 rails 代码中的 ProductType 赋予特殊含义。【参考方案10】:

我想我至少找到了一种更清洁的方法。

我喜欢联系 ThoughtBot 以获得推荐的“官方”解决方案的想法。目前,这很好用。

我只是将在测试代码中执行此操作的方法与在工厂定义中执行所有操作相结合。

Factory.define :cart_with_two_add_items, :parent => :cart do |o|
  o.after_build do |cart|
    prod_type = Factory(:add_product_type) # Define locally here and reuse below

    cart.cart_items = [Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_users_product,
                                                   :product_type => prod_type)),
                       Factory(:cart_item,
                               :cart => cart,
                               :product => Factory(:added_profiles_product,
                                                   :product_type => prod_type))]
  end
end

def test_cart_with_same_item_types
  cart = Factory(:cart_with_two_add_items)
  # ... Do asserts
end

如果我找到更好的解决方案,我会更新。

【讨论】:

以上是关于在具有唯一约束的关联中使用 Rails 中的 factory_girl。得到重复的错误的主要内容,如果未能解决你的问题,请参考以下文章

由于多列上的唯一索引,无法更新 Rails 中的父记录和嵌套记录

使用一个查询递增具有唯一约束的字段中的一组值,Postgres

MySQL中的唯一约束

rails中的多态关联

查找列是不是具有唯一约束

唯一约束