Skip to content

Commit 4c5e01b

Browse files
committed
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
1 parent 4fffccc commit 4c5e01b

File tree

6 files changed

+132
-3
lines changed

6 files changed

+132
-3
lines changed

lib/active_resource/associations.rb

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ module Builder
1616
# [:class_name]
1717
# Specify the class name of the association. This class name would
1818
# be used for resolving the association class.
19+
# [:foreign_key]
20+
# Specify the foreign key used for the association. By default, the key is
21+
# inferred from the associated `element_name` class method with an "_id"
22+
# suffix.
23+
# [:primary_key]
24+
# Specify the primary key used for the association. By default, the key is
25+
# inferred from the `primary_key` class method.
1926
#
2027
# ==== Example for [:class_name] - option
2128
# GET /posts/123.json delivers following response body:
@@ -50,6 +57,13 @@ def has_many(name, options = {})
5057
# [:class_name]
5158
# Specify the class name of the association. This class name would
5259
# be used for resolving the association class.
60+
# [:foreign_key]
61+
# Specify the foreign key used for the association. By default, the key is
62+
# inferred from the associated `element_name` class method with an "_id"
63+
# suffix.
64+
# [:primary_key]
65+
# Specify the primary key used for the association. By default, the key is
66+
# inferred from the `primary_key` class method.
5367
#
5468
# ==== Example for [:class_name] - option
5569
# GET /posts/1.json delivers following response body:
@@ -141,14 +155,18 @@ def defines_belongs_to_finder_method(reflection)
141155
def defines_has_many_finder_method(reflection)
142156
method_name = reflection.name
143157
ivar_name = :"@#{method_name}"
158+
options = reflection.options
144159

145160
define_method(method_name) do
161+
foreign_key = options.fetch(:foreign_key, "#{self.class.element_name}_id")
162+
primary_key = send(options.fetch(:primary_key, self.class.primary_key))
163+
146164
if instance_variable_defined?(ivar_name)
147165
instance_variable_get(ivar_name)
148166
elsif attributes.include?(method_name)
149167
read_attribute(method_name)
150168
elsif !new_record?
151-
instance_variable_set(ivar_name, reflection.klass.where("#{self.class.element_name}_id": self.id))
169+
instance_variable_set(ivar_name, reflection.klass.where(foreign_key => primary_key))
152170
else
153171
instance_variable_set(ivar_name, self.class.collection_parser.new)
154172
end
@@ -159,16 +177,20 @@ def defines_has_many_finder_method(reflection)
159177
def defines_has_one_finder_method(reflection)
160178
method_name = reflection.name
161179
ivar_name = :"@#{method_name}"
180+
options = reflection.options
162181

163182
define_method(method_name) do
183+
foreign_key = options.fetch(:foreign_key, "#{self.class.element_name}_id")
184+
primary_key = send(options.fetch(:primary_key, self.class.primary_key))
185+
164186
if instance_variable_defined?(ivar_name)
165187
instance_variable_get(ivar_name)
166188
elsif attributes.include?(method_name)
167189
read_attribute(method_name)
168190
elsif reflection.klass.respond_to?(:singleton_name)
169-
instance_variable_set(ivar_name, reflection.klass.find(params: { "#{self.class.element_name}_id": self.id }))
191+
instance_variable_set(ivar_name, reflection.klass.find(params: { foreign_key => primary_key }))
170192
else
171-
instance_variable_set(ivar_name, reflection.klass.find(:one, from: "/#{self.class.collection_name}/#{self.id}/#{method_name}#{self.class.format_extension}"))
193+
instance_variable_set(ivar_name, reflection.klass.find(:one, from: "/#{self.class.collection_name}/#{primary_key}/#{method_name}#{self.class.format_extension}"))
172194
end
173195
end
174196
end

lib/active_resource/associations/builder/has_many.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module ActiveResource::Associations::Builder
44
class HasMany < Association
5+
self.valid_options += [ :primary_key, :foreign_key ]
6+
57
self.macro = :has_many
68

79
def build

lib/active_resource/associations/builder/has_one.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module ActiveResource::Associations::Builder
44
class HasOne < Association
5+
self.valid_options += [ :primary_key, :foreign_key ]
6+
57
self.macro = :has_one
68

79
def build

test/cases/association_test.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
require "fixtures/person"
66
require "fixtures/beast"
77
require "fixtures/customer"
8+
require "fixtures/weather"
89

910

1011
class AssociationTest < ActiveSupport::TestCase
1112
def setup
1213
@klass = ActiveResource::Associations::Builder::Association
1314
@reflection = ActiveResource::Reflection::AssociationReflection.new :belongs_to, :customer, {}
15+
@previous_reflections, External::Person.reflections = External::Person.reflections, {}
1416
end
1517

18+
def teardown
19+
External::Person.reflections = @previous_reflections
20+
end
1621

1722
def test_validations_for_instance
1823
object = @klass.new(Person, :customers, {})
@@ -48,6 +53,28 @@ def test_has_many
4853
assert_equal [ "Related" ], people.map(&:name)
4954
end
5055

56+
def test_has_many_with_primary_key
57+
External::Person.has_many(:people, primary_key: :parent_id)
58+
59+
ActiveResource::HttpMock.respond_to.get "/people.json?person_id=1", {}, { people: [ { id: 2, name: "Related" } ] }.to_json
60+
person = External::Person.new({ parent_id: 1 }, true)
61+
62+
people = person.people
63+
64+
assert_equal [ "Related" ], people.map(&:name)
65+
end
66+
67+
def test_has_many_with_foreign_key
68+
External::Person.has_many(:people, foreign_key: :parent_id)
69+
70+
ActiveResource::HttpMock.respond_to.get "/people.json?parent_id=1", {}, { people: [ { id: 2, name: "Related" } ] }.to_json
71+
person = External::Person.new({ id: 1 }, true)
72+
73+
people = person.people
74+
75+
assert_equal [ "Related" ], people.map(&:name)
76+
end
77+
5178
def test_has_many_chain
5279
External::Person.send(:has_many, :people)
5380

@@ -68,6 +95,62 @@ def test_has_many_on_new_record
6895
def test_has_one
6996
External::Person.send(:has_one, :customer)
7097
assert_equal 1, External::Person.reflections.select { |name, reflection| reflection.macro.eql?(:has_one) }.count
98+
99+
ActiveResource::HttpMock.respond_to.get "/people/1/customer.json", {}, { person: { id: 2, name: "Customer" } }.to_json
100+
person = External::Person.new({ id: 1 }, true)
101+
102+
customer = person.customer
103+
104+
assert_equal "Customer", customer.name
105+
end
106+
107+
def test_has_one_singleton
108+
External::Person.send(:has_one, :weather)
109+
110+
ActiveResource::HttpMock.respond_to.get "/weather.json?person_id=1", {}, { weather: { id: 1, status: "Sunshine" } }.to_json
111+
person = External::Person.new({ id: 1 }, true)
112+
113+
weather = person.weather
114+
115+
assert_equal "Sunshine", weather.status
116+
end
117+
118+
def test_has_one_with_primary_key
119+
External::Person.send(:has_one, :customer, primary_key: :customer_id)
120+
121+
ActiveResource::HttpMock.respond_to.get "/people/1/customer.json", {}, { person: { id: 2, name: "Customer" } }.to_json
122+
person = External::Person.new({ customer_id: 1 }, true)
123+
124+
customer = person.customer
125+
126+
assert_equal "Customer", customer.name
127+
end
128+
129+
def test_has_one_singleton_with_primary_key
130+
External::Person.send(:has_one, :weather, primary_key: :person_id)
131+
132+
ActiveResource::HttpMock.respond_to.get "/weather.json?person_id=1", {}, { weather: { id: 1, status: "Sunshine" } }.to_json
133+
person = External::Person.new({ person_id: 1 }, true)
134+
135+
weather = person.weather
136+
137+
assert_equal "Sunshine", weather.status
138+
end
139+
140+
def test_has_one_singleton_with_foreign_key
141+
previous_prefix = Weather.prefix
142+
Weather.prefix = "/people/:owner_id/"
143+
144+
External::Person.send(:has_one, :weather, foreign_key: :owner_id)
145+
146+
ActiveResource::HttpMock.respond_to.get "/people/1/weather.json", {}, { weather: { id: 1, status: "Sunshine" } }.to_json
147+
person = External::Person.new({ id: 1 }, true)
148+
149+
weather = person.weather
150+
151+
assert_equal "Sunshine", weather.status
152+
ensure
153+
Weather.prefix = previous_prefix
71154
end
72155

73156
def test_belongs_to

test/cases/associations/builder/has_many_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,14 @@ def test_instance_build
2525
assert_equal :street_address, reflection.name
2626
assert_equal StreetAddress, reflection.klass
2727
end
28+
29+
def test_valid_options
30+
assert @klass.build(Person, :street_address, class_name: "StreetAddress")
31+
assert @klass.build(Person, :street_address, foreign_key: "person_id")
32+
assert @klass.build(Person, :street_address, primary_key: "id")
33+
34+
assert_raise ArgumentError do
35+
@klass.build(Person, :street_address, soo_invalid: true)
36+
end
37+
end
2838
end

test/cases/associations/builder/has_one_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,14 @@ def test_instance_build
2525
assert_equal :inventory, reflection.name
2626
assert_equal Inventory, reflection.klass
2727
end
28+
29+
def test_valid_options
30+
assert @klass.build(Product, :inventory, class_name: "Product")
31+
assert @klass.build(Product, :inventory, foreign_key: "product_id")
32+
assert @klass.build(Product, :inventory, primary_key: "id")
33+
34+
assert_raise ArgumentError do
35+
@klass.build(Product, :inventory, soo_invalid: true)
36+
end
37+
end
2838
end

0 commit comments

Comments
 (0)