Skip to content

Commit a332dd9

Browse files
committed
Allow Active Record to derive the association query constraints
Active Record already has the ability to guess the foreign key. With query constraints we can use the information from the owner class and that foreign key to infer what the query constraints should be for an association. This change improves the ergonomics of the query constraints feature by making it so that you only have to define the query constraints on the top level model and skip defining it on the association. There are a few caveats to this as of this PR: - If the query constraints on the parent are greater than 2, Rails can't derive the association constraints - If the query constraints on the parent don't include the primary key, Rails can't derive the association constraints. - If the association has an explicit `foreign_key` or `query_constraints` option set, Active Record won't infer the key. - I have not implemented support for CPK yet as it's more complex and maybe not possible since we don't know which key to split on and use for the foreign association. Prior to this change you would need to do the following to use query constraints: ```ruby class Post query_constraints :blog_id, :id has_many :comments, query_constraints: [:blog_id, :blog_post_id] end class Comment query_constraints :blog_id, :id belongs_to :blog, query_constraints: [:blog_id, :blog_post_id] end ``` After this change, the associations don't require you to set the query constraints, Active Record will generate `[:blog_id, :blog_post_id]` foreign key itself. ```ruby class Post query_constraints :blog_id, :id has_many :comments end class Comment query_constraints :blog_id, :id belongs_to :blog end ```
1 parent 621bc68 commit a332dd9

File tree

7 files changed

+90
-9
lines changed

7 files changed

+90
-9
lines changed

activerecord/lib/active_record/autosave_association.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ def compute_primary_key(reflection, record)
532532
primary_key_options
533533
elsif reflection.options[:query_constraints] && (query_constraints = record.class.query_constraints_list)
534534
query_constraints
535+
elsif record.class.has_query_constraints? && !reflection.options[:foreign_key]
536+
record.class.query_constraints_list
535537
else
536538
record.class.primary_key
537539
end

activerecord/lib/active_record/reflection.rb

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,16 @@ def foreign_key(infer_from_inverse_of: true)
502502
@foreign_key ||= if options[:query_constraints]
503503
# composite foreign keys support
504504
options[:query_constraints].map { |fk| fk.to_s.freeze }.freeze
505+
elsif options[:foreign_key]
506+
options[:foreign_key].to_s
505507
else
506-
-(options[:foreign_key]&.to_s || derive_foreign_key(infer_from_inverse_of: infer_from_inverse_of))
508+
derived_fk = derive_foreign_key(infer_from_inverse_of: infer_from_inverse_of)
509+
510+
if active_record.has_query_constraints?
511+
derived_fk = derive_fk_query_constraints(active_record, derived_fk)
512+
end
513+
514+
derived_fk
507515
end
508516
end
509517

@@ -759,6 +767,50 @@ def derive_foreign_key(infer_from_inverse_of: true)
759767
end
760768
end
761769

770+
def derive_fk_query_constraints(klass, foreign_key)
771+
primary_query_constraints = klass.query_constraints_list
772+
owner_pk = klass.primary_key
773+
774+
if primary_query_constraints.size != 2
775+
raise ArgumentError, <<~MSG.squish
776+
The query constraints list on the `#{klass}` model has more than 2
777+
attributes. Active Record is unable to derive the query constraints
778+
for the association. You need to explicitly define the query constraints
779+
for this association.
780+
MSG
781+
end
782+
783+
if !primary_query_constraints.include?(owner_pk)
784+
raise ArgumentError, <<~MSG.squish
785+
The query constraints on the `#{klass}` model does not include the primary
786+
key so Active Record is unable to derive the foreign key constraints for
787+
the association. You need to explicitly define the query constraints for this
788+
association.
789+
MSG
790+
end
791+
792+
# The primary key and foreign key are both already in the query constraints
793+
# so we don't want to derive the key. In this case we want a single key.
794+
if primary_query_constraints.include?(owner_pk) && primary_query_constraints.include?(foreign_key)
795+
return foreign_key
796+
end
797+
798+
first_key, last_key = primary_query_constraints
799+
800+
if first_key == owner_pk
801+
[foreign_key, last_key.to_s]
802+
elsif last_key == owner_pk
803+
[first_key.to_s, foreign_key]
804+
else
805+
raise ArgumentError, <<~MSG.squish
806+
Active Record couldn't correctly interpret the query constraints
807+
for the `#{klass}` model. The query constraints on `#{klass}` are
808+
`#{primary_query_constraints}` and the foreign key is `#{foreign_key}`.
809+
You need to explicitly set the query constraints for this association.
810+
MSG
811+
end
812+
end
813+
762814
def derive_join_table
763815
ModelSchema.derive_join_table_name active_record.table_name, klass.table_name
764816
end

activerecord/test/cases/associations_test.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class AssociationsTest < ActiveRecord::TestCase
4545
fixtures :accounts, :companies, :developers, :projects, :developers_projects,
4646
:computers, :people, :readers, :authors, :author_addresses, :author_favorites,
4747
:comments, :posts, :sharded_blogs, :sharded_blog_posts, :sharded_comments, :sharded_tags, :sharded_blog_posts_tags,
48-
:cpk_orders
48+
:cpk_orders, :cpk_books, :cpk_reviews
4949

5050
def test_eager_loading_should_not_change_count_of_children
5151
liquid = Liquid.create(name: "salty")
@@ -225,6 +225,18 @@ def test_has_many_association_from_a_model_with_query_constraints_different_from
225225
assert_equal(expected_comments.sort, comments.sort)
226226
end
227227

228+
def test_query_constraints_over_three_without_defining_explicit_foreign_key_query_constraints_raises
229+
Sharded::BlogPostWithRevision.has_many :comments_without_query_constraints, primary_key: [:blog_id, :id], class_name: "Comment"
230+
blog_post = sharded_blog_posts(:great_post_blog_one)
231+
blog_post = Sharded::BlogPostWithRevision.find(blog_post.id)
232+
233+
error = assert_raises ArgumentError do
234+
blog_post.comments_without_query_constraints.to_a
235+
end
236+
237+
assert_equal "The query constraints list on the `Sharded::BlogPostWithRevision` model has more than 2 attributes. Active Record is unable to derive the query constraints for the association. You need to explicitly define the query constraints for this association.", error.message
238+
end
239+
228240
def test_model_with_composite_query_constraints_has_many_association_sql
229241
blog_post = sharded_blog_posts(:great_post_blog_one)
230242

@@ -315,6 +327,20 @@ def test_assign_composite_foreign_key_belongs_to_association
315327
assert_equal(another_blog.id, comment.blog_id)
316328
end
317329

330+
def test_query_constraints_that_dont_include_the_primary_key_raise
331+
original = Sharded::BlogPost.instance_variable_get(:@query_constraints_list)
332+
Sharded::BlogPost.query_constraints :title, :revision
333+
Sharded::BlogPost.has_many :comments_without_query_constraints, primary_key: [:blog_id, :id], class_name: "Comment"
334+
blog_post = sharded_blog_posts(:great_post_blog_one)
335+
336+
error = assert_raises ArgumentError do
337+
blog_post.comments_without_query_constraints.to_a
338+
end
339+
340+
assert_equal "The query constraints on the `Sharded::BlogPost` model does not include the primary key so Active Record is unable to derive the foreign key constraints for the association. You need to explicitly define the query constraints for this association.", error.message
341+
ensure
342+
Sharded::BlogPost.instance_variable_set(:@query_constraints_list, original)
343+
end
318344

319345
def test_assign_belongs_to_cpk_model_by_id_attribute
320346
order = cpk_orders(:cpk_groceries_order_1)

activerecord/test/models/sharded/blog_post.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ class BlogPost < ActiveRecord::Base
66
query_constraints :blog_id, :id
77

88
belongs_to :blog
9-
has_many :comments, query_constraints: [:blog_id, :blog_post_id]
10-
has_many :delete_comments, class_name: "Sharded::Comment", query_constraints: [:blog_id, :blog_post_id], dependent: :delete_all
9+
has_many :comments
10+
has_many :delete_comments, class_name: "Sharded::Comment", dependent: :delete_all
1111

12-
has_many :blog_post_tags, query_constraints: [:blog_id, :blog_post_id]
12+
has_many :blog_post_tags
1313
has_many :tags, through: :blog_post_tags
1414
end
1515
end

activerecord/test/models/sharded/blog_post_tag.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
module Sharded
44
class BlogPostTag < ActiveRecord::Base
55
self.table_name = :sharded_blog_posts_tags
6+
query_constraints :blog_id, :id
67

7-
belongs_to :blog_post, query_constraints: [:blog_id, :blog_post_id]
8-
belongs_to :tag, query_constraints: [:blog_id, :tag_id]
8+
belongs_to :blog_post
9+
belongs_to :tag
910
end
1011
end

activerecord/test/models/sharded/comment.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Comment < ActiveRecord::Base
55
self.table_name = :sharded_comments
66
query_constraints :blog_id, :id
77

8-
belongs_to :blog_post, query_constraints: [:blog_id, :blog_post_id]
8+
belongs_to :blog_post
99
belongs_to :blog_post_by_id, class_name: "Sharded::BlogPost", foreign_key: :blog_post_id
1010
belongs_to :blog
1111
end

activerecord/test/models/sharded/tag.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Tag < ActiveRecord::Base
55
self.table_name = :sharded_tags
66
query_constraints :blog_id, :id
77

8-
has_many :blog_post_tags, query_constraints: [:blog_id, :tag_id]
8+
has_many :blog_post_tags
99
has_many :blog_posts, through: :blog_post_tags
1010
end
1111
end

0 commit comments

Comments
 (0)