通过factory_girl关联查找或创建记录

75

我有一个属于组的用户模型。组必须具有唯一的名称属性。用户工厂和组工厂已定义为:

Factory.define :user do |f|
  f.association :group, :factory => :group
  # ...
end

Factory.define :group do |f|
  f.name "default"
end
当创建第一个用户时,也会创建一个新组。当我尝试创建第二个用户时,它会失败,因为它想要再次创建相同的组。
有没有办法告诉factory_girl关联方法首先查找现有记录?
注意:我确实尝试过定义一个处理此问题的方法,但然后我无法使用f.association。我希望能够像这样在Cucumber场景中使用它:
Given the following user exists:
  | Email          | Group         |
  | test@email.com | Name: mygroup |

只有在工厂定义中使用关联时,才能使此方法起作用。

9个回答

114
您可以使用find_or_create方法来使用initialize_with
FactoryGirl.define do
  factory :group do
    name "name"
    initialize_with { Group.find_or_create_by_name(name)}
  end

  factory :user do
    association :group
  end
end

它也可以与id一起使用

FactoryGirl.define do
  factory :group do
    id     1
    attr_1 "default"
    attr_2 "default"
    ...
    attr_n "default"
    initialize_with { Group.find_or_create_by_id(id)}
  end

  factory :user do
    association :group
  end
end

针对Rails 4

Rails 4 中的正确方式是使用 Group.find_or_create_by(name: name),所以您可以这样使用:

initialize_with { Group.find_or_create_by(name: name) } 

改为使用 "instead"。


6
非常好用,谢谢。在Rails 4中,更推荐使用Group.find_or_create_by(name: name)的方式。 - migu
21
在Rails 4中首选的方式实际上是 Group.where(name: name).first_or_create - Laurens
3
一般情况下,这个方法不起作用:Factory Girl会重置已创建的模型。 1. create(:user) 2. Group.first.update_attributes(name: "new name") 3. create(:user)。现在 Group.first.name == "name" 成立,步骤3重置了步骤2。在复杂的Cucumber设置中,这种情况很容易发生。对此有什么建议吗? - Sebastian vom Meer
7
不错,但我建议使用find_or_initialize_by,这样它就能与FactoryGirl的build一起使用。 - Kelvin
5
我会跟进 @Kelvin 的回答,并提到,做这件事的“安全”方法是同时传递原始属性,以便它们不会被初始化为 nilModel.where(name: name).first_or_initialize(attributes) - Hari Gopal
显示剩余3条评论

19

我最终使用了从网络上找到的各种方法,其中一个是继承工厂,正如duckyfuzz在另一个答案中建议的那样。

我做了以下操作:

# in groups.rb factory

def get_group_named(name)
  # get existing group or create new one
  Group.where(:name => name).first || Factory(:group, :name => name)
end

Factory.define :group do |f|
  f.name "default"
end

# in users.rb factory

Factory.define :user_in_whatever do |f|
  f.group { |user| get_group_named("whatever") }
end

8
你可以使用FactoryGirl策略来实现这一点。
module FactoryGirl
  module Strategy
    class Find
      def association(runner)
        runner.run
      end

      def result(evaluation)
        build_class(evaluation).where(get_overrides(evaluation)).first
      end

      private

      def build_class(evaluation)
        evaluation.instance_variable_get(:@attribute_assigner).instance_variable_get(:@build_class)
      end

      def get_overrides(evaluation = nil)
        return @overrides unless @overrides.nil?
        evaluation.instance_variable_get(:@attribute_assigner).instance_variable_get(:@evaluator).instance_variable_get(:@overrides).clone
      end
    end

    class FindOrCreate
      def initialize
        @strategy = FactoryGirl.strategy_by_name(:find).new
      end

      delegate :association, to: :@strategy

      def result(evaluation)
        found_object = @strategy.result(evaluation)

        if found_object.nil?
          @strategy = FactoryGirl.strategy_by_name(:create).new
          @strategy.result(evaluation)
        else
          found_object
        end
      end
    end
  end

  register_strategy(:find, Strategy::Find)
  register_strategy(:find_or_create, Strategy::FindOrCreate)
end

您可以使用这个代码片段。接着,按照以下步骤进行操作。
FactoryGirl.define do
  factory :group do
    name "name"
  end

  factory :user do
    association :group, factory: :group, strategy: :find_or_create, name: "name"
  end
end

这对我来说很有效,尽管如此。

这似乎不再起作用了。我为最新版本的FactoryBot创建了一个新的代码片段:https://dev59.com/dmw05IYBdhLWcg3wmC6_#65700370 - thisismydesign

6

我曾遇到类似问题并想出了这个解决方案。它会查找名称匹配的组,如果找到则将用户与该组关联。否则,它会创建一个名称相同的新组,并将用户与其关联。

factory :user do
  group { Group.find_by(name: 'unique_name') || FactoryBot.create(:group, name: 'unique_name') }
end

我希望这对某人有用 :)

5
为了确保FactoryBot的 build 和 create 仍能正常工作,我们应该只覆盖 create 的逻辑,可以这样做:
factory :user do
  association :group, factory: :group
  # ...
end

factory :group do
  to_create do |instance|
    instance.id = Group.find_or_create_by(name: instance.name).id
    instance.reload
  end

  name { "default" }
end

这确保了build保持其默认行为,即“构建/初始化对象”,不执行任何数据库读写操作,因此始终很快。只有create的逻辑被覆盖,如果存在,则获取现有记录,而不是尝试始终创建新记录。

我写了一篇文章来解释这个。


3

我正在寻找一种不影响工厂的方式。正如 @Hiasinho 指出的那样,创建一个 策略 是可行的方法。然而,那个解决方案对我来说已经不再起作用了,可能是 API 发生了变化。于是我想到了这个:

module FactoryBot
  module Strategy
    # Does not work when passing objects as associations: `FactoryBot.find_or_create(:entity, association: object)`
    # Instead do: `FactoryBot.find_or_create(:entity, association_id: id)`
    class FindOrCreate
      def initialize
        @build_strategy = FactoryBot.strategy_by_name(:build).new
      end

      delegate :association, to: :@build_strategy

      def result(evaluation)
        attributes = attributes_shared_with_build_result(evaluation)
        evaluation.object.class.where(attributes).first || FactoryBot.strategy_by_name(:create).new.result(evaluation)
      end

      private

      # Here we handle possible mismatches between initially provided attributes and actual model attrbiutes
      # For example, devise's User model is given a `password` and generates an `encrypted_password`
      # In this case, we shouldn't use `password` in the `where` clause
      def attributes_shared_with_build_result(evaluation)
        object_attributes = evaluation.object.attributes
        evaluation.hash.filter { |k, v| object_attributes.key?(k.to_s) }
      end
    end
  end

  register_strategy(:find_or_create, Strategy::FindOrCreate)
end

并像这样使用它:

org = FactoryBot.find_or_create(:organization, name: 'test-org')
user = FactoryBot.find_or_create(:user, email: 'test@test.com', password: 'test', organization: org)

2
通常我只是创建多个工厂定义。一个用于具有组的用户,另一个用于无组用户:
Factory.define :user do |u|
  u.email "email"
  # other attributes
end

Factory.define :grouped_user, :parent => :user do |u|
  u.association :group
  # this will inherit the attributes of :user
end

然后,您可以在步骤定义中使用这些内容来单独创建用户和组,并随意将它们组合在一起。例如,您可以创建一个分组用户和一个单独的用户,并将单独的用户加入到分组用户的团队中。
无论如何,您应该查看pickle gem,它将允许您编写类似以下步骤:
Given a user exists with email: "hello@email.com"
And a group exists with name: "default"
And the user: "hello@gmail.com" has joined that group
When somethings happens....

0

我正在使用你在问题中描述的确切的Cucumber场景:

Given the following user exists:
  | Email          | Group         |
  | test@email.com | Name: mygroup |

你可以像这样扩展它:

Given the following user exists:
  | Email          | Group         |
  | test@email.com | Name: mygroup |
  | foo@email.com  | Name: mygroup |
  | bar@email.com  | Name: mygroup |

这将创建3个属于组“mygroup”的用户。由于它使用了“find_or_create_by”功能,第一次调用将创建该组,接下来的两次调用将找到已经创建的组。


0

另一种方法是(适用于任何属性和关联):

# config/initializers/factory_bot.rb
#
# Example use:
#
# factory :my_factory do
#   change_factory_to_find_or_create
#
#   some_attr { 7 }
#   other_attr { "hello" }
# end
#
# FactoryBot.create(:my_factory) # creates
# FactoryBot.create(:my_factory) # finds
# FactoryBot.create(:my_factory, other_attr: "new value") # creates
# FactoryBot.create(:my_factory, other_attr: "new value") # finds

module FactoryBotEnhancements
  def change_factory_to_find_or_create
    to_create do |instance|
      # Note that this will ignore nil value attributes, to avoid auto-generated attributes such as id and timestamps
      attributes = instance.class.find_or_create_by(instance.attributes.compact).attributes
      instance.attributes = attributes.except('id')
      instance.id = attributes['id'] # id can't be mass-assigned
      instance.instance_variable_set('@new_record', false) # marks record as persisted
    end
  end
end

# This makes the module available to all factory definition blocks
class FactoryBot::DefinitionProxy
  include FactoryBotEnhancements
end

唯一的注意事项是您无法按nil值查找。除此之外,它运作得非常好。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接