factory_bot icon indicating copy to clipboard operation
factory_bot copied to clipboard

How to handle polymorphic associations using exclusive arc

Open NoahTheDuke opened this issue 3 years ago • 2 comments

Situation

Currently, modeling polymorphic associations is pretty simple, as demonstrated in GETTING_STARTED.md. However, I have a polymorphic association using the Exclusive Belongs To/Exclusive Arc method (also seen here using Rails), which makes the above solution not work anymore because I have to make two optional associations instead of a single polymorphic one (I've left out the validation logic for clarity):

class Team < ActiveRecord::Base
  has_many :posts
end

class User < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :team, optional: true
  belongs_to :user, optional: true
end

This leads me to have to awkwardly define the associations in an after(:build) block, relying on a transient and traits that only set the transient to determine which of the two associated models to build/create:

FactoryBot.define do
  factory :team
  factory :user

  factory :post do
    transient do
      # default to :team
      team_or_user { :team }
    end

    trait :team_owned do
      team_or_user { :team }
    end

    trait :user_owned do
      team_or_user { :user }
    end

    after(:build) do |post, evaluator|
      if evaluator.team_or_user == :team
        post.team = evaluator.team || evaluator.association(:team, strategy: :build)
      elsif evaluator.team_or_user == :user
        post.user = evaluator.user || evaluator.association(:user, strategy: :build)
      end
    end
  end
end

As seen above, I call strategy: :build, but that's not ideal because I don't know the chosen strategy (which could lead to issues or missing data in other strategies). I also have to rely on the consumers of this factory to know to pass in team or user without being able to see it in the main factory body (defined like a regular association). I feel like there are other edge cases I'm not considering yet, but I haven't come across them yet. Finally, this is a lot of work to get a "simple" association to work properly.

Question/Discussion

Is this the best solution? Any ideas for better ways to do this that aren't so cumbersome? It makes me wish for an "anti-trait", a way to say "in this invocation, ignore the specified trait" so I could just put the associations in their traits and then put the anti-traits in the opposite traits, making it "obvious":

  factory :post do
    team_owned
    trait :team_owned do
      anti_trait :user_owned
      team
    end

    trait :user_owned do
      anti_trait :team_owned
      user
    end
  end

REPRODUCTION_SCRIPT.txt

NoahTheDuke avatar May 06 '21 19:05 NoahTheDuke

Hi Noah, you bring up a great point; however, build strategies traits and transient need to accommodate many combinations, so perhaps some more questions can uncover this a bit more. In the example, the main goal seems to have a default trait that satisfies the database constraints. Could it be possible to achieve the same results utilizing an inline-block definition for the association? I think that adding a default transient value overridden by the specific traits you described in the example could produce an acceptable result/experience? Maybe worth considering that being explicit about this particular type of association (polymorphic associations always get me 😅) could help future readers notice the unique behavior. Not something directly related but something that popped up while thinking about how to use it.

aledustet avatar May 07 '21 18:05 aledustet

Thanks for the idea! I tried that out and it didn't work as hoped, but I did find a way that works for my use-case: defining a "base" factory and then making a nested factory the default.

FactoryBot.define do
  factory :team
  factory :user

  factory :post_base, class: Post do
    factory :post, aliases: [:team_post] do
      team
    end

    factory :user_post do
      user
    end
  end
end

I have the urge to say it's not quite ideal as the class isn't a UserPost but a Post with a defined User, but after wrestling with it, I think that's just nitpicking and demanding consistency where none is necessary or helpful.

Would this be something worth mentioning in the guide? I can open a PR if so. Otherwise, we can close this as solved.

NoahTheDuke avatar May 10 '21 14:05 NoahTheDuke