From d52fd1d0df4db9ed4b423b7efa1b86876773426e Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Mon, 24 Nov 2025 13:22:57 -0500 Subject: [PATCH] Associations: Support `:primary_key` and `:foreign_key` The problem --- Some `has_many` and `has_one` associations provided by APIs are "joined" by properties other than their `id` (or an overridden `primary_key`). The proposal --- By default, continue to infer the `:foreign_key` as `self.class.element_name + "_id"` and its `:primary_key` from the value of its primary key (returned by the `id` method). When necessary, `has_many` declarations can declare `:primary_key` and `:foreign_key` options in the same style as [Active Record's `has_many` associations][has_many]. [has_many]: https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many --- lib/active_resource/associations.rb | 28 +++++- .../associations/builder/has_many.rb | 2 + .../associations/builder/has_one.rb | 2 + test/cases/association_test.rb | 98 +++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/lib/active_resource/associations.rb b/lib/active_resource/associations.rb index 696ea57473..453b1b418d 100644 --- a/lib/active_resource/associations.rb +++ b/lib/active_resource/associations.rb @@ -16,6 +16,13 @@ module Builder # [:class_name] # Specify the class name of the association. This class name would # be used for resolving the association class. + # [:foreign_key] + # Specify the foreign key used for the association. By default, the key is + # inferred from the associated `element_name` class method with an "_id" + # suffix. + # [:primary_key] + # Specify the primary key used for the association. By default, the key is + # inferred from the `primary_key` class method. # # ==== Example for [:class_name] - option # GET /posts/123.json delivers following response body: @@ -50,6 +57,13 @@ def has_many(name, options = {}) # [:class_name] # Specify the class name of the association. This class name would # be used for resolving the association class. + # [:foreign_key] + # Specify the foreign key used for the association. By default, the key is + # inferred from the associated `element_name` class method with an "_id" + # suffix. + # [:primary_key] + # Specify the primary key used for the association. By default, the key is + # inferred from the `primary_key` class method. # # ==== Example for [:class_name] - option # GET /posts/1.json delivers following response body: @@ -141,14 +155,18 @@ def defines_belongs_to_finder_method(reflection) def defines_has_many_finder_method(reflection) method_name = reflection.name ivar_name = :"@#{method_name}" + options = reflection.options define_method(method_name) do + foreign_key = options.fetch(:foreign_key, "#{self.class.element_name}_id") + primary_key = send(options.fetch(:primary_key, self.class.primary_key)) + if instance_variable_defined?(ivar_name) instance_variable_get(ivar_name) elsif attributes.include?(method_name) read_attribute(method_name) elsif !new_record? - instance_variable_set(ivar_name, reflection.klass.where("#{self.class.element_name}_id": self.id)) + instance_variable_set(ivar_name, reflection.klass.where(foreign_key => primary_key)) else instance_variable_set(ivar_name, self.class.collection_parser.new) end @@ -159,16 +177,20 @@ def defines_has_many_finder_method(reflection) def defines_has_one_finder_method(reflection) method_name = reflection.name ivar_name = :"@#{method_name}" + options = reflection.options define_method(method_name) do + foreign_key = options.fetch(:foreign_key, "#{self.class.element_name}_id") + primary_key = send(options.fetch(:primary_key, self.class.primary_key)) + if instance_variable_defined?(ivar_name) instance_variable_get(ivar_name) elsif attributes.include?(method_name) read_attribute(method_name) elsif reflection.klass.respond_to?(:singleton_name) - instance_variable_set(ivar_name, reflection.klass.find(params: { "#{self.class.element_name}_id": self.id })) + instance_variable_set(ivar_name, reflection.klass.find(params: { foreign_key => primary_key })) else - instance_variable_set(ivar_name, reflection.klass.find(:one, from: "/#{self.class.collection_name}/#{self.id}/#{method_name}#{self.class.format_extension}")) + instance_variable_set(ivar_name, reflection.klass.find(:one, from: "/#{self.class.collection_name}/#{primary_key}/#{method_name}#{self.class.format_extension}")) end end end diff --git a/lib/active_resource/associations/builder/has_many.rb b/lib/active_resource/associations/builder/has_many.rb index 84c860037a..776c9dd11c 100644 --- a/lib/active_resource/associations/builder/has_many.rb +++ b/lib/active_resource/associations/builder/has_many.rb @@ -2,6 +2,8 @@ module ActiveResource::Associations::Builder class HasMany < Association + self.valid_options += [ :primary_key, :foreign_key ] + self.macro = :has_many def build diff --git a/lib/active_resource/associations/builder/has_one.rb b/lib/active_resource/associations/builder/has_one.rb index d83ed48790..db643090d2 100644 --- a/lib/active_resource/associations/builder/has_one.rb +++ b/lib/active_resource/associations/builder/has_one.rb @@ -2,6 +2,8 @@ module ActiveResource::Associations::Builder class HasOne < Association + self.valid_options += [ :primary_key, :foreign_key ] + self.macro = :has_one def build diff --git a/test/cases/association_test.rb b/test/cases/association_test.rb index a84fe63d60..5cecc34441 100644 --- a/test/cases/association_test.rb +++ b/test/cases/association_test.rb @@ -51,6 +51,34 @@ def test_has_many External::Person.remove_method(:people) if reflection end + def test_has_many_with_primary_key + External::Person.has_many(:people, primary_key: :parent_id) + + ActiveResource::HttpMock.respond_to.get "/people.json?person_id=1", {}, { people: [ { id: 2, name: "Related" } ] }.to_json + person = External::Person.new({ parent_id: 1 }, true) + + people = person.people + + assert_equal [ "Related" ], people.map(&:name) + ensure + reflection = External::Person.reflections.delete(:people) + External::Person.remove_method(:people) if reflection + end + + def test_has_many_with_foreign_key + External::Person.has_many(:people, foreign_key: :parent_id) + + ActiveResource::HttpMock.respond_to.get "/people.json?parent_id=1", {}, { people: [ { id: 2, name: "Related" } ] }.to_json + person = External::Person.new({ id: 1 }, true) + + people = person.people + + assert_equal [ "Related" ], people.map(&:name) + ensure + reflection = External::Person.reflections.delete(:people) + External::Person.remove_method(:people) if reflection + end + def test_has_many_chain External::Person.send(:has_many, :people) @@ -74,6 +102,76 @@ def test_has_many_on_new_record def test_has_one External::Person.send(:has_one, :customer) assert_equal 1, External::Person.reflections.select { |name, reflection| reflection.macro.eql?(:has_one) }.count + + ActiveResource::HttpMock.respond_to.get "/people/1/customer.json", {}, { person: { id: 2, name: "Customer" } }.to_json + person = External::Person.new({ id: 1 }, true) + + customer = person.customer + + assert_equal "Customer", customer.name + ensure + reflection = External::Person.reflections.delete(:customer) + External::Person.remove_method(:customer) if reflection + end + + def test_has_one_singleton + External::Person.send(:has_one, :weather) + + ActiveResource::HttpMock.respond_to.get "/weather.json?person_id=1", {}, { weather: { id: 1, status: "Sunshine" } }.to_json + person = External::Person.new({ id: 1 }, true) + + weather = person.weather + + assert_equal "Sunshine", weather.status + ensure + reflection = External::Person.reflections.delete(:weather) + External::Person.remove_method(:weather) if reflection + end + + def test_has_one_with_primary_key + External::Person.send(:has_one, :customer, primary_key: :customer_id) + + ActiveResource::HttpMock.respond_to.get "/people/1/customer.json", {}, { person: { id: 2, name: "Customer" } }.to_json + person = External::Person.new({ customer_id: 1 }, true) + + customer = person.customer + + assert_equal "Customer", customer.name + ensure + reflection = External::Person.reflections.delete(:customer) + External::Person.remove_method(:customer) if reflection + end + + def test_has_one_singleton_with_primary_key + External::Person.send(:has_one, :weather, primary_key: :person_id) + + ActiveResource::HttpMock.respond_to.get "/weather.json?person_id=1", {}, { weather: { id: 1, status: "Sunshine" } }.to_json + person = External::Person.new({ person_id: 1 }, true) + + weather = person.weather + + assert_equal "Sunshine", weather.status + ensure + reflection = External::Person.reflections.delete(:weather) + External::Person.remove_method(:weather) if reflection + end + + def test_has_one_singleton_with_foreign_key + previous_prefix = Weather.prefix + Weather.prefix = "/people/:owner_id/" + + External::Person.send(:has_one, :weather, foreign_key: :owner_id) + + ActiveResource::HttpMock.respond_to.get "/people/1/weather.json", {}, { weather: { id: 1, status: "Sunshine" } }.to_json + person = External::Person.new({ id: 1 }, true) + + weather = person.weather + + assert_equal "Sunshine", weather.status + ensure + Weather.prefix = previous_prefix + reflection = External::Person.reflections.delete(:weather) + External::Person.remove_method(:weather) if reflection end def test_belongs_to