-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Fix association override precedence over trait foreign keys #1768
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
2b10252
to
1448588
Compare
…tbot#1767) * Ensure association overrides take precedence over trait-defined foreign keys In 6.5.5, changes related to PR thoughtbot#1709 introduced an unintended behavior where a trait-defined `*_id` could take precedence over an explicit association override. This could lead to inconsistency between the association and its foreign key. * Update `AttributeAssigner#aliased_attribute?` to prioritize associations When an association override is provided (e.g., `user: user_instance`), the corresponding trait-defined foreign key is ignored, keeping the object graph consistent with Rails/ActiveRecord expectations. The fix preserves the intent of PR thoughtbot#1709 regarding attribute/attribute_id conflicts. * Add regression specs - Association override wins over trait-defined foreign key - Trait-defined foreign key applies when no association override is present - Multiple foreign-key traits remain supported Example: Before (6.5.5): FactoryBot.build(:post, :with_user_id_999, user: user).user_id # => 999 After (this change; consistent with 6.5.4): FactoryBot.build(:post, :with_user_id_999, user: user).user_id # => user.id Related: thoughtbot#1709, thoughtbot#1767
1448588
to
27953c4
Compare
Adds a comment explaining why association overrides take precedence over trait-defined foreign keys in AttributeAssigner#aliased_attribute?.
Thanks, @JinOketani! I'm playing a bit with the code and trying to find ways to break it. I'm calling it a day today, but I'll be back to continue reviewing this PR next week. |
Thanks @neilvcarvalho! Hope this helps. Here are the reproduction steps. Environment
Expected Behavior (v6.5.4 and earlier)When passing both a trait with a foreign key and an explicit association, the explicit association should take precedence and the foreign key should be consistent. book = create(:book)
book_author = build(:book_author, :with_book_id_999, book: book)
# Expected:
book_author.book_id # => book.id (1)
book_author.book # => book (consistent) Actual Behavior (v6.5.5)The trait-defined foreign key overrides the explicit association, creating inconsistent state. book = create(:book)
book_author = build(:book_author, :with_book_id_999, book: book)
# Actual:
book_author.book_id # => 999 (from trait)
book_author.book # => nil (association ignored) Reproduction Steps1. Set up models and factoriesModels: class Author < ApplicationRecord
has_many :book_authors, dependent: :destroy
has_many :books, through: :book_authors
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
class Book < ApplicationRecord
has_many :book_authors, dependent: :destroy
has_many :authors, through: :book_authors
validates :title, presence: true
validates :isbn, presence: true, uniqueness: true
validates :published_at, presence: true
end
class BookAuthor < ApplicationRecord
belongs_to :book
belongs_to :author
validates :book, presence: true
validates :author, presence: true
validates :role, inclusion: { in: ['author', 'co-author', 'editor', 'translator'] }
end Factories: FactoryBot.define do
factory :book_author do
role { 'author' }
contribution_percentage { 100 }
notes { 'Primary author of this work' }
active { true }
association :book
association :author
# This trait reproduces the PR #1768 regression
trait :with_book_id_999 do
book_id { 999 }
end
end
factory :author do
sequence(:name) { |n| "Author #{n}" }
sequence(:email) { |n| "author#{n}@example.com" }
bio { "A talented writer" }
active { true }
end
factory :book do
sequence(:title) { |n| "Book Title #{n}" }
sequence(:isbn) { |n| "978-#{sprintf('%010d', n)}" }
published_at { 1.year.ago }
genre { "Fiction" }
active { true }
end
end 2. Create test case that demonstrates the issueRSpec.describe 'FactoryBot: Trait Foreign Keys Override Explicit Associations' do
it 'demonstrates trait foreign key overriding explicit association' do
# Create a book we want to associate
book = create(:book, title: 'Expected Book')
# This should use the explicit book association, ignoring trait's book_id
book_author = build(:book_author, :with_book_id_999, book: book)
puts "Expected book_id: #{book.id}"
puts "Actual book_id: #{book_author.book_id}"
puts "Association: #{book_author.book.inspect}"
# This fails - trait foreign key wins over explicit association
expect(book_author.book_id).to eq(book.id),
"Expected explicit association to override trait foreign key"
expect(book_author.book).to eq(book),
"Expected association to be set correctly"
end
end 3. Run the test to see the failurebundle exec rspec spec/models/book_author_factory_regression_spec.rb Expected Output:
|
FWIW, this worked for me and didn't introduce any other regressions (that I noticed, at least). Thanks @JinOketani. @CodeMeister any concerns with this, based on the intentions / implementation of your previous changes? |
It’s been a while since this was opened, but I wanted to follow up on it. I agree that this is a relatively minor change, but since it addresses a potential underlying issue, I’d prefer not to leave the bug unpatched. It would be great if we could merge this to keep the library stable. @neilvcarvalho How does that sound to you? |
@oehlschl looks good to me, nicely done 🚀 |
To this point, we're currently pinned to v6.5.4 because of this issue and will remove that constraint once this is released. |
Thank you for submitting this and I apologize that it's taken us a while to review it. I've been digging through the code and comparing v6.5.4, v6.5.5, and the fix you've submitted here. The code in this PR does seem to fix the regression you found. It also preserves the behavior from the #1709 fix. However, I found another regression that exists in both the released v6.5.5 and the code in this PR. This regression also relates to overrides. When I first started digging, I wanted to confirm the behavior of declaring both describe "attribute aliases" do
before do
define_model("User", name: :string, age: :integer)
define_model("Post", user_id: :integer, title: :string) do
belongs_to :user
end
end
context "defers to the last declared attribute amongst alias matches" do
it "defers to the :user_id attribute when declared after the :user attribute" do
FactoryBot.define do
factory :user
factory :post do
user
user_id { 99 }
end
end
post = FactoryBot.build(:post)
expect(post.user_id).to eq 99
expect(post.user).to eq nil
end
it "defers to the :user attribute when declared after the :user_id attribute" do
FactoryBot.define do
factory :user
factory :post do
user_id { 99 }
user
end
end
post = FactoryBot.build(:post)
expect(post.user_id).to eq nil
expect(post.user).to be_an_instance_of(User)
end
end
end After additional digging, I stumbled upon a scenario where the code from v6.5.4 passes, but the code contained in v6.5.5 and this PR fail: describe "attribute aliases" do
before do
define_model("User", name: :string, age: :integer)
define_model("Post", user_id: :integer, title: :string) do
belongs_to :user
end
end
context "when overrides include a :user_id foreign key" do
it "handles setting the user association" do
FactoryBot.define do
factory :user
factory :post do
user
user_id { 999 }
end
end
user = FactoryBot.create(:user)
post = FactoryBot.create(:post, user_id: user.id)
expect(post.user).to eq user
expect(post.user_id).to eq user.id
expect(User.count).to be 1
end
end
end
Some aspect of the original logic found in |
Oops, my
|
🤔 🧠 Sharing a brain dump as I think through this (in case it spurs ideas)
The more I dig in, the more it seems that aliases could use some rework. Though that seems like something that would require either a major or minor version release (and careful design). I'd like to patch/fix the regression before that though 🤔 I too don't like leaving v6.5.5 unpatched |
factory_bot v6.5.5 included a patch which worked around the previous limitaion of not being able to assign both <attribute> and <attribute>_id as independent attributes. The change introduced regressions into how attributes are assigned. This commit fixes regressions involving declared attributes which are aliases of overrides where an association is involved. It fixes behavior where the attributes are not assigned in the expected manner as well as fixes an instance where an extra erroneous associated object is created.
@JinOketani and @neilvcarvalho I've expanded upon JinOkeani's original code to additionally address the regression I found. I'd love to hear what others think 🙂 |
Also sorry about the few messy commits, I think I've been at the computer too long and I'm going to step away now 🤣 |
Hello, this is my first contribution to factory_bot. While using it, I came across what seems to be an unexpected behavior, so I’d like to propose a fix. This PR fixes #1767 — since v6.5.5, associations cannot override trait-defined ID attributes.
Background / Problem
In 6.5.4, passing an association explicitly would override any *_id values provided by traits or factory defaults. After 6.5.5 (related to PR #1709), the *_id value can take precedence, causing the association and foreign key to diverge.
Root Cause
The issue stems from changes made in PR #1709 to resolve attribute/attribute_id conflicts. The modified
AttributeAssigner#aliased_attribute?
method now returnsfalse
when the override is an actual attribute name, but this inadvertently affects association overrides.Solution / Approach
Refine alias/override precedence so that:
This change enhances the alias check in AttributeAssigner to ignore the *_id value when the corresponding association is explicitly overridden. While this serves as a temporary fix for the bug, a more fundamental solution may ultimately be needed.
Changes
Tests
Backward Compatibility / Risk
Before / After