-
Notifications
You must be signed in to change notification settings - Fork 21.4k
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
AR nullifies one of the primary_key if we nullify the belongs_to association with composite_keys #49671
Comments
You need to change your configuration to this for things to start working diff --git a/bug_report.rb b/bug_report.rb
index ea70bb0..335c529 100644
--- a/bug_report.rb
+++ b/bug_report.rb
@@ -27,6 +27,7 @@ ActiveRecord::Schema.define do
t.integer :id, null: false
t.integer :organization_id, null: false
t.integer :group_id
+ t.integer :group_organization_id
end
end
@@ -40,7 +41,7 @@ class Item < ActiveRecord::Base
belongs_to :group,
class_name: 'Item',
optional: true,
- query_constraints: %i[group_id organization_id]
+ query_constraints: %i[group_id group_organization_id]
end
class BugTest < Minitest::Test |
This is intended behavior of the current API. Since class Car < ApplicationRecord
self.primary_key = [:make, :model]
end
class CarBrochure < ApplicationRecord
belongs_to :car, query_constraints: [:car_make, :car_model]
end And when you do The challenge here is to know that I guess one thing we could try is to make Rails to look into presence of an association where class CarBrochure < ApplicationRecord
belongs_to :car_make, foreign_key: :car_make
belongs_to :car, query_constraints: [:car_make, :car_model]
end where we still want both So to summarize, this is an unfortunate limitation of the current API. I think what we'd want in this case is to have a special option that can tell that belongs_to :group,
class_name: 'Item',
optional: true,
foreign_key: :group_id,
tenant_key: :organization_id which will let Rails know that |
Maybe, we can leverage existing options? More options => more complexity. I think, the real limitation and the source of confusion with the current API is the naming: when I see "query_constraints" I expect it to be used for building queries only, not for setting foreign keys. One option I'd like to explore is making the following work as we expect (by "we" I mean @prog-supdex and myself 🙂): belongs_to :group,
class_name: 'Item',
optional: true,
foreign_key: :group_id,
query_constraints: %i[group_id organization_id] So, if the For that, we may want to refactor the #foreign_key method (or introduce another one for building queries). |
Fair, it just happened to do both since we deliberately didn't extend
I actually like this idea. I think somewhere deep in the design thoughts this is how I imagined it to be. And maybe this is the answer for the future if Rails ever allows passing One of the challenges we need to figure out is how to properly map the foreign_key = :group_id
query_constraints = [:group_id, :organization_id]
primary_key = [:id, :organization_id]
pk_column_group_id_fk_points_to = ??? # should be somehow resolved to `:id` So a few options: pk_column_group_id_fk_points_to = (primary_key - query_constraints).first # => :id Relies on the fact that "the other" column will serve as a tenant key and will be called the same for both Or perhaps a little bit more verbose option where we derive the "tenant key" concept first: tenant_key = (query_constraints - foreign_key).first
pk_column_group_id_fk_points_to = (primary_key - tenant_key).first This heavily relies on the fact that PK/query constraints setup consists of 2 columns where one of the columns behaves like a tenant key but I believe this is going to be a very common setup so might be worth teaching Rails about supporting this particular use-case first. Also I don't think we should use "tenant key" name in the actual code but I don't have a better name at the moment 🙃 |
I think I might s/intended/expected/ there -- it's certainly desired in the case of a true two+ part foreign key, but it does seem bad (and tbh not something that had occurred to me) in the common-tenant-key case. There's also a related edge case of "assigning a non-nil object that has a different tenant key", where "raise that your data model makes that impossible" is probably a more appropriate response than "silently reparent the current record". It doesn't get us there globally, but perhaps a first cut (which I'm leaning toward calling a bug fix) would be to exempt columns derived from the parent model's query constraints from overwriting assignments in consumer associations: those are by definition expected to be shared with other associations and the model itself, and so should be rewritten directly when intended? I also find the "foreign_key as subset of query_constraints" approach interesting (and API-available, as they're currently mutually exclusive) as a possible way of affecting this at the single association level... but yeah reconciling that with the association's |
Yeah, that's about how we temporary solved it.
That's interesting: what if take other associations into account and perform replacement depending on that? For example: ActiveRecord::Schema.define do
create_table :organizations do
end
create_table :items, primary_key: %i[id organization_id] do |t|
t.integer :id, null: false
t.integer :organization_id, null: false
t.integer :group_id
end
end
class Organization < ActiveRecord::Base
end
class Item < ActiveRecord::Base
belongs_to :group,
class_name: 'Item',
optional: true,
query_constraints: %i[group_id organization_id]
end
class OrganizedItem < Item
belongs_to :organization
end
class PrimaryItem < Item
self.primary_key = %i[id organization_id]
end
organization = Organization.create!
first_item = Item.create!(id: [1, organization.id])
# Here, we set both group_id and organization_id, we consider them to be a foreign key for the association
base_item = Item.new(group: first_item)
base_item.organization_id #=> first_item.organization_id
base_item.group_id #=> first_item.id.first
# Here, we know that there is an association using organization_id as a foreign_key
organized_item = OrganizedItem.new(organization:, group: first_item)
organized_item.organization_id #=> organization.id
organized_item.group_id #=> first_item.id.first
organized_item.group = nil
# Stays as is because its referred by another association
organized_item.organization_id #=> organization.id
organized_item.group_id #=> nil
# Another feature—consistency check!
organized_item = OrganizedItem.new(group: first_item) #=> raise "Foreign key doesn't match: nil vs <>"
# Similarly, when we have a composite primary key
primary_item = PrimaryItem.new(group: first_item)
# We shouldn't set the primary key from the association
primary_item.organization_id #=> nil
primary_item.group_id #=> first_item.id.first
# Not sure how to enforce consistency in this case, though Seems rather complicated but.. the whole feature is like that; so, another option worth exploring. |
We ran into this recently as well (using the CPK gem) which is what I believe the native rails CPK code is based on. I then noticed the issue persisted all the way through to the port of the code into rails mainline. Here is an issue I opened and subsequently closed once I realized what was going on. For our rails and CPK version we fixed this behavior temporarily with a monkey patch: module ActiveRecord
module AttributeMethods
module Write
# Patched from https://github.com/composite-primary-keys/composite_primary_keys/blob/13.0.7/lib/composite_primary_keys/attribute_methods/write.rb
# Method identical in https://github.com/composite-primary-keys/composite_primary_keys/blob/14.0.7/lib/composite_primary_keys/attribute_methods/write.rb
def _write_attribute(attr_name, value) # :nodoc:
# CPK
if attr_name.kind_of?(Array)
attr_name.each_with_index do |attr_child_name, i|
child_value = value ? value[i] : value
# BEGIN PATCH
# Skip changing attributes that are part of the primary key unless the whole PK is being changed explicitly
# or the part of the PK we want to change is currently null.
next if composite? && self.class.primary_key.include?(attr_child_name) && public_send(attr_child_name).present?
# END PATCH
@attributes.write_from_user(attr_child_name.to_s, child_value)
end
else
@attributes.write_from_user(attr_name.to_s, value)
end
value
end
end
end
end I think from an user perspective, if columns in the relationship are shared with columns in the primary key, it would NOT be expected that your primary key values change. I propose the correct behavior is to only allow changing of one/some/all of the primary key columns when the value is currently null (since I believe PK columns must all be NOT NULL) and its likely you are trying to set/change them in that case. Once the PK columns have values, they should not change IMO. And if someone wishes to change them, they could technically still be modified directly. |
We recently ran into a similar but different issue because we are not using a composite primary key, but a "regular" single column primary key named (In this example, we're assuming a multi-tenant blog app where multiple blogs are stored, and each post and comment belong to a blog, with a # typed: false
# frozen_string_literal: true
require "bundler/inline"
require 'debug'
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
# If you want to test against edge Rails replace the previous line with this:
# gem "rails", github: "rails/rails", branch: "main"
gem "sqlite3"
end
require "active_record"
require "minitest/autorun"
require "logger"
# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.integer(:blog_id)
end
create_table :comments, force: true do |t|
t.integer(:blog_id)
t.integer(:post_id)
end
end
class Post < ActiveRecord::Base
self.primary_key = :id
query_constraints :id, :blog_id
has_many :comments
end
class Comment < ActiveRecord::Base
self.primary_key = :id
query_constraints :id, :blog_id
belongs_to :post
end
class BugTest < Minitest::Test
def test_association_nullifies_additional_key
post = Post.create!(blog_id: 1)
comment = Comment.create(blog_id: 1, post: post)
# This works, only changes `post_id`
comment.update!(post_id: nil)
assert_equal(1, comment.blog_id)
# Now reset the comment
comment.update!(post_id: post.id)
comment.reload
# This also changes `blog_id` on `comments`
comment.update!(post: nil)
assert_equal(1, comment.blog_id) # Fails
end
end I do agree with the suggestion from @matthewd, seems like that would work for us. For context, I also tried a few workarounds with extra association options: Explicitly configuring the association with the following restores the expected behavior to not override belongs_to :post, inverse_of: :comments, foreign_key: :post_id, primary_key: :id But now calling Attempting to restore this with the following causes two more issues: belongs_to :post, inverse_of: :comments, foreign_key: :post_id, primary_key: :id, query_constraints: [:post_id, :blog_id]
|
Hey folks, we allocated some time to work on this and I have a proposal on how we are going to address it. The ideaDecouple How we are going to achieve it
The approximate timeline for that would be to introduce deprecations in Rails 7.2 potentially along with the prototype of Here is a commit that shows the basic idea, it's far from being complete but sufficient to get reproduction scripts in this issue working with a slight and meaningful configuration change. As was already mentioned, belongs_to :group,
class_name: 'Item',
optional: true,
foreign_key: :group_id,
query_constraints: %i[group_id organization_id] making And Let me know if you see any immediate issues or have any concerns, but on paper this seems to be a correct decision and honestly something that we aimed for from the beginning so I'd like to pursue it. |
Steps to reproduce
Expected behavior
The expected behaviour for the
test_nullify_association
case is not nullifying the compositeprimary_keys
of the Item record.The expected behaviour for the
test_adding_association
case doesn't add anorganization_id
Actual behavior
In the case
test_nullify_association
we get one of the Item's composite primary_keysorganization_id
as nilIn the case
test_adding_association
we get filled fieldorganization_id
System configuration
Rails version: 7.1.1 and also main
Ruby version: 3.2.2
The text was updated successfully, but these errors were encountered: