Find or create record through factory_girl association
Asked Answered
C

9

77

I have a User model that belongs to a Group. Group must have unique name attribute. User factory and group factory are defined as:

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

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

When the first user is created a new group is created too. When I try to create a second user it fails because it wants to create same group again.

Is there a way to tell factory_girl association method to look first for an existing record?

Note: I did try to define a method to handle this, but then I cannot use f.association. I would like to be able to use it in Cucumber scenarios like this:

Given the following user exists:
  | Email          | Group         |
  | [email protected] | Name: mygroup |

and this can only work if association is used in Factory definition.

Carothers answered 22/8, 2011 at 9:10 Comment(0)
C
19

I ended up using a mix of methods found around the net, one of them being inherited factories as suggested by duckyfuzz in another answer.

I did following:

# 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
Carothers answered 27/8, 2011 at 8:2 Comment(0)
G
114

You can to use initialize_with with find_or_create method

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

It can also be used with 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

For Rails 4

The correct way in Rails 4 is Group.find_or_create_by(name: name), so you'd use

initialize_with { Group.find_or_create_by(name: name) } 

instead.

Glucose answered 3/8, 2012 at 16:27 Comment(8)
Works very well, thanks. In Rails 4, the preferred way would be: Group.find_or_create_by(name: name)Sporades
The preferred way in Rails 4 would actually be Group.where(name: name).first_or_create.Transience
This should probably be the accepted answer. Drive-by-ers... here's your solution.Loewe
This doesn't work in general: Factory Girl resets the created model. 1. create(:user) 2. Group.first.update_attributes(name: "new name") 3. create(:user). Now Group.first.name == "name" is true, step 3 resets step 2. In a complex cucumber setup this can happen very easily. Any suggestions for this?Bulimia
Rails 4 preferred way: Group.find_or_create_by(name: name)Sepulchre
This is good. But I suggest find_or_initialize_by so it works w/ FactoryGirl's build.Baltoslavic
What if the find_or_create_by requires association too?Whitaker
I'll expand upon @Baltoslavic 's answer and mention that the safe method to do this is to pass in original attributes as well, so that they're not initialized as nil: Model.where(name: name).first_or_initialize(attributes)Northumberland
C
19

I ended up using a mix of methods found around the net, one of them being inherited factories as suggested by duckyfuzz in another answer.

I did following:

# 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
Carothers answered 27/8, 2011 at 8:2 Comment(0)
W
8

You can also use a FactoryGirl strategy to achieve this

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

You can use this gist. And then do the following

FactoryGirl.define do
  factory :group do
    name "name"
  end

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

This is working for me, though.

Wallaroo answered 5/1, 2015 at 10:52 Comment(1)
This doesn't seem to work anymore. I created a new snippet for the latest version of FactoryBot: https://mcmap.net/q/264903/-find-or-create-record-through-factory_girl-associationDamek
W
7

To ensure FactoryBot's build and create still behaves as it should, we should only override the logic of create, by doing:

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

This ensures build maintains it's default behavior of "building/initializing the object" and does not perform any database read or write so it's always fast. Only logic of create is overridden to fetch an existing record if exists, instead of attempting to always create a new record.

I wrote an article explaining this.

Warden answered 19/3, 2019 at 7:49 Comment(0)
P
7

I had a similar problem and came up with this solution. It looks for a group by name and if it is found it associates the user with that group. Otherwise it creates a group by that name and then associates with it.

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

I hope this can be useful to someone :)

Phyte answered 4/10, 2019 at 13:20 Comment(0)
D
3

I was looking for a way that doesn't affect the factories. Creating a Strategy is the way to go, as pointed out by @Hiasinho. However, that solution didn't work for me anymore, probably the API changed. Came up with this:

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

And use it like this:

org = FactoryBot.find_or_create(:organization, name: 'test-org')
user = FactoryBot.find_or_create(:user, email: '[email protected]', password: 'test', organization: org)
Damek answered 13/1, 2021 at 10:48 Comment(0)
S
2

Usually I just make multiple factory definitions. One for a user with a group and one for a groupless user:

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

THen you can use these in your step definitions to create users and groups seperatly and join them together at will. For example you could create one grouped user and one lone user and join the lone user to the grouped users team.

Anyway, you should take a look at the pickle gem which will allow you to write steps like:

Given a user exists with email: "[email protected]"
And a group exists with name: "default"
And the user: "[email protected]" has joined that group
When somethings happens....
Slobbery answered 24/8, 2011 at 20:53 Comment(0)
S
0

I'm using exactly the Cucumber scenario you described in your question:

Given the following user exists:
  | Email          | Group         |
  | [email protected] | Name: mygroup |

You can extend it like:

Given the following user exists:
  | Email          | Group         |
  | [email protected] | Name: mygroup |
  | [email protected]  | Name: mygroup |
  | [email protected]  | Name: mygroup |

This will create 3 users with the group "mygroup". As it used like this uses 'find_or_create_by' functionality, the first call creates the group, the next two calls finds the already created group.

Side answered 17/2, 2012 at 10:44 Comment(0)
S
0

Another way to do it (that will work with any attribute and work with associations):

# 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

The only caveat is that you can't find by nil values. Other than that, it works like a dream

Starflower answered 8/3, 2021 at 23:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.