From 98790602896bbe93195cface4b448d859dae3721 Mon Sep 17 00:00:00 2001 From: Daniel Colson Date: Thu, 9 Jul 2020 12:26:23 -0400 Subject: [PATCH] Improve documentation for collection associations (#1420) * Improve documentation for collection associations This commit makes `Evaluator#association` and `Evaluator#instance` officially part of the public API of factory\_bot. Folks were already using these methods, and all of the examples here were inspired by examples in the various issues mentioned below. This commit also adds and updates some tests to match the examples in the documentation, to ensure that these examples will continue to work as expected. Closes #1268 by adding documentation for both `Evaluator#association` and `Evaluator#instance`. The documentation on interconnected associations is also relevant to #1063, #1255, and #1309. Closes #1304 by providing multiple alternative approaches to creating collection associations, and adding a note about reloading the record in the callback example. This had also come up back in #549. Closes #458 by offering `Evaluator#association` as a more flexible way to build collection associations. This has come up many times over the years in many different forms, including #426, #487, #640, #1022, #1150, and #1360. --- GETTING_STARTED.md | 217 +++++++++++++++++++++------ spec/acceptance/associations_spec.rb | 69 +++++++-- 2 files changed, 226 insertions(+), 60 deletions(-) diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 57736e6..592a255 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -44,6 +44,7 @@ Getting Started + [`has_many` associations](#has_many-associations) + [`has_and_belongs_to_many` associations](#has_and_belongs_to_many-associations) + [Polymorphic associations](#polymorphic-associations) + + [Interconnected associations](#interconnected-associations) * [Sequences](#sequences) + [Global sequences](#global-sequences) + [With dynamic attributes](#with-dynamic-attributes) @@ -653,27 +654,51 @@ factory :post do ### `has_many` associations -Generating data for a `has_many` relationship is a bit more involved, -depending on the amount of flexibility desired, but here's a surefire example -of generating associated data. +There are a few ways to generate data for a `has_many` relationship. The +simplest approach is to write a helper method in plain Ruby to tie together the +different records: + +```ruby +FactoryBot.define do + factory :post do + title { "Through the Looking Glass" } + user + end + + factory :user do + name { "Rachel Sanchez" } + end +end + +def user_with_posts(posts_count: 5) + FactoryBot.create(:user) do |user| + FactoryBot.create_list(:post, posts_count, user: user) + end +end + +create(:user).posts.length # 0 +user_with_posts.posts.length # 5 +user_with_posts(posts_count: 15).posts.length # 15 +``` + +If you prefer to keep the object creation fully within factory\_bot, you can +build the posts in an `after(:create)` callback. + ```ruby FactoryBot.define do - - # post factory with a `belongs_to` association for the user factory :post do title { "Through the Looking Glass" } user end - # user factory without associated posts factory :user do name { "John Doe" } # user_with_posts will create post data after the user has been created factory :user_with_posts do - # posts_count is declared as a transient attribute and available in - # attributes on the factory, as well as the callback via the evaluator + # posts_count is declared as a transient attribute avialabile in the + # callback via the evaluator transient do posts_count { 5 } end @@ -684,71 +709,122 @@ FactoryBot.define do # to create and we make sure the user is associated properly to the post after(:create) do |user, evaluator| create_list(:post, evaluator.posts_count, user: user) + + # You may need to reload the record here, depending on your application + user.reload end end end end -``` -This allows us to do: - -```ruby create(:user).posts.length # 0 create(:user_with_posts).posts.length # 5 create(:user_with_posts, posts_count: 15).posts.length # 15 ``` -### `has_and_belongs_to_many` associations - -Generating data for a `has_and_belongs_to_many` relationship is very similar -to the above `has_many` relationship, with a small change, you need to pass an -array of objects to the model's pluralized attribute name rather than a single -object to the singular version of the attribute name. - -Here's an example with two models that are related via - `has_and_belongs_to_many`: +Or, for a solution that works with `build`, `build_stubbed`, and `create` +(although it doesn't work well with `attributes_for`), you can use inline +associations: ```ruby FactoryBot.define do - - # language factory with a `belongs_to` association for the profile - factory :language do + factory :post do title { "Through the Looking Glass" } - profile + user end - # profile factory without associated languages - factory :profile do - name { "John Doe" } + factory :user do + name { "Taylor Kim" } - # profile_with_languages will create language data after the profile has - # been created - factory :profile_with_languages do - # languages_count is declared as an ignored attribute and available in - # attributes on the factory, as well as the callback via the evaluator + factory :user_with_posts do + posts { [association(:post)] } + end + end +end + +create(:user).posts.length # 0 +create(:user_with_posts).posts.length # 1 +build(:user_with_posts).posts.length # 1 +build_stubbed(:user_with_posts).posts.length # 1 +``` + +For more flexibility you can combine this with the `posts_count` transient +attribute from the callback example: + +```ruby +FactoryBot.define do + factory :post do + title { "Through the Looking Glass" } + user + end + + factory :user do + name { "Adiza Kumato" } + + factory :user_with_posts do transient do - languages_count { 5 } + posts_count { 5 } end - # the after(:create) yields two values; the profile instance itself and - # the evaluator, which stores all values from the factory, including - # ignored attributes; `create_list`'s second argument is the number of - # records to create and we make sure the profile is associated properly - # to the language - after(:create) do |profile, evaluator| - create_list(:language, evaluator.languages_count, profiles: [profile]) + posts do + Array.new(posts_count) { association(:post) } end end end end + +create(:user_with_posts).posts.length # 5 +create(:user_with_posts, posts_count: 15).posts.length # 15 +build(:user_with_posts, posts_count: 15).posts.length # 15 +build_stubbed(:user_with_posts, posts_count: 15).posts.length # 15 ``` -This allows us to do: +### `has_and_belongs_to_many` associations + +Generating data for a `has_and_belongs_to_many` relationship is very similar +to the above `has_many` relationship, with a small change: you need to pass an +array of objects to the model's pluralized attribute name rather than a single +object to the singular version of the attribute name. + ```ruby -create(:profile).languages.length # 0 -create(:profile_with_languages).languages.length # 5 -create(:profile_with_languages, languages_count: 15).languages.length # 15 +def profile_with_languages(languages_count: 2) + FactoryBot.create(:profile) do |profile| + FactoryBot.create_list(:language, languages_count, profiles: [profile]) + end +end +``` + +Or with the callback approach: + +```ruby +factory :profile_with_languages do + transient do + languages_count { 2 } + end + + after(:create) do |profile, evaluator| + create_list(:language, evaluator.languages_count, profiles: [profile]) + profile.reload + end +end +``` + +Or the inline association approach (note the use of the `instance` method here +to refer to the profile being built): + +```ruby +factory :profile_with_languages do + transient do + languages_count { 2 } + end + + languages do + Array.new(languages_count) do + association(:language, profiles: [instance]) + end + end +end ``` ### Polymorphic associations @@ -782,6 +858,57 @@ create(:comment, :for_video) create(:comment, :for_photo) ``` +### Interconnected associations + +There are limitless ways objects might be interconnected, and +factory\_bot may not always be suited to handle those relationships. In some +cases it makes sense to use factory\_bot to build each individual object, and +then to write helper methods in plain Ruby to tie those objects together. + +That said, some more complex, interconnected relationships can be built in factory\_bot +using inline associations with reference to the `instance` being built. + +Let's say your models look like this, where an associated `Student` and +`Profile` should both belong to the same `School`: + +```ruby +class Student < ApplicationRecord + belongs_to :school + has_one :profile +end + +class Profile < ApplicationRecord + belongs_to :school + belongs_to :student +end + +class School < ApplicationRecord + has_many :students + has_many :profiles +end +``` + +We can ensure the student and profile are connected to each other and to the +same school with a factory like this: + +```ruby +FactoryBot.define do + factory :student do + school + profile { association :profile, student: instance, school: school } + end + + factory :profile do + school + student { association :student, profile: instance, school: school } + end + + factory :school +end +``` + +Note that this approach works with `build`, `build_stubbed`, and `create`, but +the associations will return `nil` when using `attributes_for`. Sequences --------- diff --git a/spec/acceptance/associations_spec.rb b/spec/acceptance/associations_spec.rb index b14882a..2ec9fd7 100644 --- a/spec/acceptance/associations_spec.rb +++ b/spec/acceptance/associations_spec.rb @@ -59,48 +59,87 @@ describe "associations" do end it "connects records with interdependent relationships" do - define_model("User", school_id: :integer) do + define_model("Student", school_id: :integer) do belongs_to :school has_one :profile end - define_model("Profile", school_id: :integer, user_id: :integer) do + define_model("Profile", school_id: :integer, student_id: :integer) do belongs_to :school - belongs_to :user + belongs_to :student end define_model("School") do - has_many :users + has_many :students has_many :profiles end FactoryBot.define do - factory :user do + factory :student do school - profile { association :profile, user: instance, school: school } + profile { association :profile, student: instance, school: school } end factory :profile do school - user { association :user, profile: instance, school: school } + student { association :student, profile: instance, school: school } end factory :school end - user = FactoryBot.create(:user) + student = FactoryBot.create(:student) - expect(user.profile.school).to eq(user.school) - expect(user.profile.user).to eq(user) - expect(user.school.users.map(&:id)).to eq([user.id]) - expect(user.school.profiles.map(&:id)).to eq([user.profile.id]) + expect(student.profile.school).to eq(student.school) + expect(student.profile.student).to eq(student) + expect(student.school.students.map(&:id)).to eq([student.id]) + expect(student.school.profiles.map(&:id)).to eq([student.profile.id]) profile = FactoryBot.create(:profile) - expect(profile.user.school).to eq(profile.school) - expect(profile.user.profile).to eq(profile) + expect(profile.student.school).to eq(profile.school) + expect(profile.student.profile).to eq(profile) expect(profile.school.profiles.map(&:id)).to eq([profile.id]) - expect(profile.school.users.map(&:id)).to eq([profile.user.id]) + expect(profile.school.students.map(&:id)).to eq([profile.student.id]) + end + end + + context "when building collection associations" do + it "builds the association according to the given strategy" do + define_model("Photo", listing_id: :integer) do + belongs_to :listing + attr_accessor :name + end + + define_model("Listing") do + has_many :photos + end + + FactoryBot.define do + factory :photo + + factory :listing do + photos { [association(:photo)] } + end + end + + created_listing = FactoryBot.create(:listing) + + expect(created_listing.photos.first).to be_a Photo + expect(created_listing.photos.first).to be_persisted + + built_listing = FactoryBot.build(:listing) + + expect(built_listing.photos.first).to be_a Photo + expect(built_listing.photos.first).not_to be_persisted + + stubbed_listing = FactoryBot.build_stubbed(:listing) + + expect(stubbed_listing.photos.first).to be_a Photo + expect(stubbed_listing.photos.first).to be_persisted + expect { stubbed_listing.photos.first.save! }.to raise_error( + "stubbed models are not allowed to access the database - Photo#save!()" + ) end end end