Skip to content

Commit 88f7a1b

Browse files
author
Lee Richmond
committed
Add polymorphic associations
1 parent 26ceae8 commit 88f7a1b

File tree

7 files changed

+387
-20
lines changed

7 files changed

+387
-20
lines changed

lib/jsonapi_compliable/adapters/active_record_sideloading.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,55 @@ def has_one(association_name, scope:, resource:, foreign_key:, primary_key: :id,
6565
instance_eval(&blk) if blk
6666
end
6767
end
68+
69+
def has_and_belongs_to_many(association_name, scope:, resource:, foreign_key:, primary_key: :id, as: nil, &blk)
70+
through = foreign_key.keys.first
71+
fk = foreign_key.values.first
72+
_scope = scope
73+
74+
allow_sideload association_name, resource: resource do
75+
scope do |parents|
76+
_scope.call.joins(through).where(through => { fk => parents.map { |p| p.send(primary_key) } })
77+
end
78+
79+
assign do |parents, children|
80+
parents.each do |parent|
81+
parent.association(association_name).loaded!
82+
relevant_children = children.select { |c| c.send(through).any? { |ct| ct.send(fk) == parent.send(primary_key) } }
83+
relevant_children.each do |c|
84+
parent.association(association_name).add_to_target(c, :skip_callbacks)
85+
end
86+
end
87+
end
88+
89+
instance_eval(&blk) if blk
90+
end
91+
end
92+
93+
def polymorphic_belongs_to(association_name, group_by:, groups:, &blk)
94+
allow_sideload association_name, polymorphic: true do
95+
group_by(&group_by)
96+
97+
groups.each_pair do |type, config|
98+
primary_key = config[:primary_key] || :id
99+
foreign_key = config[:foreign_key]
100+
101+
allow_sideload type, resource: config[:resource] do
102+
scope do |parents|
103+
config[:scope].call.where(primary_key => parents.map { |p| p.send(foreign_key) })
104+
end
105+
106+
assign do |parents, children|
107+
parents.each do |parent|
108+
parent.send(:"#{association_name}=", children.find { |c| c.send(primary_key) == parent.dwelling_id })
109+
end
110+
end
111+
end
112+
end
113+
end
114+
115+
instance_eval(&blk) if blk
116+
end
68117
end
69118
end
70119
end

lib/jsonapi_compliable/scope.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,8 @@ def query_hash
3939
def sideload(results, includes)
4040
includes.each_pair do |name, nested|
4141
if @resource.allowed_sideloads.has_key?(name)
42-
sideload = @resource.sideload(name)
43-
sideload_scope = sideload.scope_proc.call(results)
44-
sideload_scope = Scope.new(sideload_scope, sideload.resource, @query, namespace: sideload.name)
45-
sideload_results = sideload_scope.resolve
46-
sideload.assign_proc.call(results, sideload_results)
42+
sideload = @resource.sideload(name)
43+
sideload.resolve(results, @query)
4744
end
4845
end
4946
end

lib/jsonapi_compliable/sideload.rb

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
module JsonapiCompliable
22
class Sideload
3-
attr_reader :name, :resource, :sideloads, :scope_proc, :assign_proc
3+
attr_reader :name,
4+
:resource,
5+
:polymorphic,
6+
:sideloads,
7+
:scope_proc,
8+
:assign_proc,
9+
:grouper
410

511
def initialize(name, opts)
6-
@name = name
7-
@resource = (opts[:resource] || Class.new(Resource)).new
8-
@sideloads = {}
12+
@name = name
13+
@resource = (opts[:resource] || Class.new(Resource)).new
14+
@sideloads = {}
15+
@polymorphic = !!opts[:polymorphic]
16+
@polymorphic_groups = {} if polymorphic?
917

1018
extend @resource.adapter.sideloading_module
1119
end
1220

21+
def polymorphic?
22+
@polymorphic == true
23+
end
24+
1325
def scope(&blk)
1426
@scope_proc = blk
1527
end
@@ -18,10 +30,29 @@ def assign(&blk)
1830
@assign_proc = blk
1931
end
2032

33+
def group_by(&grouper)
34+
@grouper = grouper
35+
end
36+
37+
def resolve(parents, query, namespace = nil)
38+
namespace ||= name
39+
40+
if polymorphic?
41+
resolve_polymorphic(parents, query)
42+
else
43+
resolve_basic(parents, query, namespace)
44+
end
45+
end
46+
2147
def allow_sideload(name, opts = {}, &blk)
2248
sideload = Sideload.new(name, opts)
2349
sideload.instance_eval(&blk) if blk
24-
@sideloads[name] = sideload
50+
51+
if polymorphic?
52+
@polymorphic_groups[name] = sideload
53+
else
54+
@sideloads[name] = sideload
55+
end
2556
end
2657

2758
def sideload(name)
@@ -35,5 +66,21 @@ def to_hash
3566
end
3667
end
3768
end
69+
70+
private
71+
72+
def resolve_polymorphic(parents, query)
73+
parents.group_by(&@grouper).each_pair do |group_type, group_members|
74+
sideload_for_group = @polymorphic_groups[group_type]
75+
sideload_for_group.resolve(group_members, query, name)
76+
end
77+
end
78+
79+
def resolve_basic(parents, query, namespace)
80+
sideload_scope = scope_proc.call(parents)
81+
sideload_scope = Scope.new(sideload_scope, resource, query, namespace: namespace)
82+
sideload_results = sideload_scope.resolve
83+
assign_proc.call(parents, sideload_results)
84+
end
3885
end
3986
end

spec/integration/resources_spec.rb

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ class BookResource < JsonapiCompliable::Resource
88
allow_filter :id
99
end
1010

11+
class DwellingResource < JsonapiCompliable::Resource
12+
type :dwellings
13+
use_adapter JsonapiCompliable::Adapters::ActiveRecord
14+
end
15+
1116
class StateResource < JsonapiCompliable::Resource
1217
type :states
1318
use_adapter JsonapiCompliable::Adapters::ActiveRecord
@@ -18,6 +23,12 @@ class BioResource < JsonapiCompliable::Resource
1823
use_adapter JsonapiCompliable::Adapters::ActiveRecord
1924
end
2025

26+
class HobbyResource < JsonapiCompliable::Resource
27+
type :hobbies
28+
allow_filter :id
29+
use_adapter JsonapiCompliable::Adapters::ActiveRecord
30+
end
31+
2132
class AuthorResource < JsonapiCompliable::Resource
2233
type :authors
2334
use_adapter JsonapiCompliable::Adapters::ActiveRecord
@@ -38,6 +49,26 @@ class AuthorResource < JsonapiCompliable::Resource
3849
foreign_key: :author_id,
3950
scope: -> { Bio.all },
4051
resource: BioResource
52+
53+
has_and_belongs_to_many :hobbies,
54+
resource: HobbyResource,
55+
scope: -> { Hobby.all },
56+
foreign_key: { author_hobbies: :author_id }
57+
58+
polymorphic_belongs_to :dwelling,
59+
group_by: proc { |author| author.dwelling_type },
60+
groups: {
61+
'House' => {
62+
foreign_key: :dwelling_id,
63+
resource: DwellingResource,
64+
scope: -> { House.all }
65+
},
66+
'Condo' => {
67+
foreign_key: :dwelling_id,
68+
resource: DwellingResource,
69+
scope: -> { Condo.all }
70+
}
71+
}
4172
end
4273
end
4374

@@ -49,15 +80,19 @@ def index
4980
end
5081
end
5182

52-
let!(:author1) { Author.create!(first_name: 'Stephen', state: state) }
53-
let!(:author2) { Author.create!(first_name: 'George') }
83+
let!(:author1) { Author.create!(first_name: 'Stephen', dwelling: house, state: state) }
84+
let!(:author2) { Author.create!(first_name: 'George', dwelling: condo) }
5485
let!(:book1) { Book.create!(author: author1, title: 'The Shining') }
5586
let!(:book2) { Book.create!(author: author1, title: 'The Stand') }
5687
let!(:state) { State.create!(name: 'Maine') }
5788
let!(:bio) { Bio.create!(author: author1, picture: 'imgur', description: 'author bio') }
89+
let!(:hobby1) { Hobby.create!(name: 'Fishing', authors: [author1]) }
90+
let!(:hobby2) { Hobby.create!(name: 'Woodworking', authors: [author1]) }
91+
let(:house) { House.new(name: 'Cozy') }
92+
let(:condo) { Condo.new(name: 'Modern') }
5893

59-
def book_ids
60-
json_includes('books').map { |b| b['id'].to_i }
94+
def ids_for(type)
95+
json_includes(type).map { |b| b['id'].to_i }
6196
end
6297

6398
it 'allows basic sorting' do
@@ -84,17 +119,17 @@ def book_ids
84119
# TODO: may want to blow up here, only for index action
85120
it 'allows pagination of sideloaded resource' do
86121
get :index, params: { include: 'books', page: { books: { size: 1, number: 2 } } }
87-
expect(book_ids).to eq([book2.id])
122+
expect(ids_for('books')).to eq([book2.id])
88123
end
89124

90125
it 'allows sorting of sideloaded resource' do
91126
get :index, params: { include: 'books', sort: '-books.title' }
92-
expect(book_ids).to eq([book2.id, book1.id])
127+
expect(ids_for('books')).to eq([book2.id, book1.id])
93128
end
94129

95130
it 'allows filtering of sideloaded resource' do
96131
get :index, params: { include: 'books', filter: { books: { id: book2.id } } }
97-
expect(book_ids).to eq([book2.id])
132+
expect(ids_for('books')).to eq([book2.id])
98133
end
99134

100135
it 'allows extra fields for sideloaded resource' do
@@ -150,6 +185,66 @@ def book_ids
150185
end
151186
end
152187

188+
context 'sideloading has_and_belongs_to_many' do
189+
it 'allows sorting of sideloaded resource' do
190+
get :index, params: { include: 'hobbies', sort: '-hobbies.name' }
191+
expect(ids_for('hobbies')).to eq([hobby2.id, hobby1.id])
192+
end
193+
194+
it 'allows filtering of sideloaded resource' do
195+
get :index, params: { include: 'hobbies', filter: { hobbies: { id: hobby2.id } } }
196+
expect(ids_for('hobbies')).to eq([hobby2.id])
197+
end
198+
199+
it 'allows extra fields for sideloaded resource' do
200+
get :index, params: { include: 'hobbies', extra_fields: { hobbies: 'reason' } }
201+
hobby = json_includes('hobbies')[0]
202+
expect(hobby['name']).to be_present
203+
expect(hobby['description']).to be_present
204+
expect(hobby['reason']).to eq('hobby reason')
205+
end
206+
207+
it 'allows sparse fieldsets for the sideloaded resource' do
208+
get :index, params: { include: 'hobbies', fields: { hobbies: 'name' } }
209+
hobby = json_includes('hobbies')[0]
210+
expect(hobby['name']).to be_present
211+
expect(hobby).to_not have_key('description')
212+
expect(hobby).to_not have_key('reason')
213+
end
214+
end
215+
216+
context 'sideloading polymorphic belongs_to' do
217+
it 'allows extra fields for the sideloaded resource' do
218+
get :index, params: {
219+
include: 'dwelling',
220+
extra_fields: { houses: 'house_price', condos: 'condo_price' }
221+
}
222+
house = json_includes('houses')[0]
223+
expect(house['name']).to be_present
224+
expect(house['house_description']).to be_present
225+
expect(house['house_price']).to eq(1_000_000)
226+
condo = json_includes('condos')[0]
227+
expect(condo['name']).to be_present
228+
expect(condo['condo_description']).to be_present
229+
expect(condo['condo_price']).to eq(500_000)
230+
end
231+
232+
it 'allows sparse fieldsets for the sideloaded resource' do
233+
get :index, params: {
234+
include: 'dwelling',
235+
fields: { houses: 'name', condos: 'condo_description' }
236+
}
237+
house = json_includes('houses')[0]
238+
expect(house['name']).to be_present
239+
expect(house).to_not have_key('house_description')
240+
expect(house).to_not have_key('house_price')
241+
condo = json_includes('condos')[0]
242+
expect(condo['condo_description']).to be_present
243+
expect(condo).to_not have_key('name')
244+
expect(condo).to_not have_key('condo_price')
245+
end
246+
end
247+
153248
context 'when overriding the resource' do
154249
before do
155250
controller.class_eval do

spec/sideload_spec.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ def foo
3737
end
3838

3939
describe '#allow_sideload' do
40-
4140
it 'assigns a new sideload' do
4241
instance.allow_sideload :bar
4342
expect(instance.sideloads[:bar]).to be_a(JsonapiCompliable::Sideload)
@@ -50,6 +49,23 @@ def foo
5049
expect(instance.sideloads[:bar].instance_variable_get(:@foo))
5150
.to eq('foo')
5251
end
52+
53+
context 'when polymorphic' do
54+
before do
55+
opts[:polymorphic] = true
56+
end
57+
58+
it 'adds a new sideload to polymorphic groups' do
59+
instance.allow_sideload :bar
60+
groups = instance.instance_variable_get(:@polymorphic_groups)
61+
expect(groups[:bar]).to be_a(JsonapiCompliable::Sideload)
62+
end
63+
64+
it 'does not add to sideloads' do
65+
instance.allow_sideload :bar
66+
expect(instance.sideloads).to be_empty
67+
end
68+
end
5369
end
5470

5571
describe '#to_hash' do

0 commit comments

Comments
 (0)