Skip to content

Commit 763af3b

Browse files
authored
Merge pull request #48 from richmolj/master
Add support for HABTM writes
2 parents df60c87 + 6870bed commit 763af3b

File tree

7 files changed

+229
-25
lines changed

7 files changed

+229
-25
lines changed

lib/jsonapi_compliable/adapters/abstract.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,46 @@ def associate(parent, child, association_name, association_type)
237237
raise 'you must override #associate in an adapter subclass'
238238
end
239239

240+
# Remove the association without destroying objects
241+
#
242+
# This is NOT needed in the standard use case. The standard use case would be:
243+
#
244+
# def update(attrs)
245+
# # attrs[:the_foreign_key] is nil, so updating the record disassociates
246+
# end
247+
#
248+
# However, sometimes you need side-effect or elsewise non-standard behavior. Consider
249+
# using {{https://github.com/mbleigh/acts-as-taggable-on acts_as_taggable_on}} gem:
250+
#
251+
# # Not actually needed, just an example
252+
# def disassociate(parent, child, association_name, association_type)
253+
# parent.tag_list.remove(child.name)
254+
# end
255+
#
256+
# @example Basic accessor
257+
# def disassociate(parent, child, association_name, association_type)
258+
# if association_type == :has_many
259+
# parent.send(association_name).delete(child)
260+
# else
261+
# child.send(:"#{association_name}=", nil)
262+
# end
263+
# end
264+
#
265+
# +association_name+ and +association_type+ come from your sideload
266+
# configuration:
267+
#
268+
# allow_sideload :the_name, type: the_type do
269+
# # ... code.
270+
# end
271+
#
272+
# @param parent The parent object (via the JSONAPI 'relationships' graph)
273+
# @param child The child object (via the JSONAPI 'relationships' graph)
274+
# @param association_name The 'relationships' key we are processing
275+
# @param association_type The Sideload type (see Sideload#type). Usually :has_many/:belongs_to/etc
276+
def disassociate(parent, child, association_name, association_type)
277+
raise 'you must override #disassociate in an adapter subclass'
278+
end
279+
240280
# This module gets mixed in to Sideload classes
241281
# This is where you define methods like has_many,
242282
# belongs_to etc that wrap the lower-level Sideload#allow_sideload

lib/jsonapi_compliable/adapters/active_record.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,24 @@ def associate(parent, child, association_name, association_type)
7575
if association_type == :has_many
7676
parent.association(association_name).loaded!
7777
parent.association(association_name).add_to_target(child, :skip_callbacks)
78+
elsif association_type == :habtm
79+
parent.send(association_name) << child
7880
else
7981
child.send("#{association_name}=", parent)
8082
end
8183
end
8284

85+
# When a has_and_belongs_to_many relationship, we don't have a foreign
86+
# key that can be null'd. Instead, go through the ActiveRecord API.
87+
# @see Adapters::Abstract#disassociate
88+
def disassociate(parent, child, association_name, association_type)
89+
if association_type == :habtm
90+
parent.send(association_name).delete(child)
91+
else
92+
# Nothing to do here, happened when we merged foreign key
93+
end
94+
end
95+
8396
# (see Adapters::Abstract#create)
8497
def create(model_class, create_params)
8598
instance = model_class.new(create_params)

lib/jsonapi_compliable/resource.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,10 +543,28 @@ def destroy(id)
543543
adapter.destroy(model, id)
544544
end
545545

546+
# Delegates #associate to adapter. Built for overriding.
547+
#
548+
# @see .use_adapter
549+
# @see Adapters::Abstract#associate
550+
# @see Adapters::ActiveRecord#associate
551+
def associate(parent, child, association_name, type)
552+
adapter.associate(parent, child, association_name, type)
553+
end
554+
555+
# Delegates #disassociate to adapter. Built for overriding.
556+
#
557+
# @see .use_adapter
558+
# @see Adapters::Abstract#disassociate
559+
# @see Adapters::ActiveRecord#disassociate
560+
def disassociate(parent, child, association_name, type)
561+
adapter.disassociate(parent, child, association_name, type)
562+
end
563+
546564
# @api private
547-
def persist_with_relationships(meta, attributes, relationships)
565+
def persist_with_relationships(meta, attributes, relationships, caller_model = nil)
548566
persistence = JsonapiCompliable::Util::Persistence \
549-
.new(self, meta, attributes, relationships)
567+
.new(self, meta, attributes, relationships, caller_model)
550568
persistence.run
551569
end
552570

lib/jsonapi_compliable/sideload.rb

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -170,24 +170,25 @@ def assign(&blk)
170170
end
171171

172172
# Configure how to associate parent and child records.
173-
#
174-
# @example Basic attr_accessor
175-
# def associate(parent, child)
176-
# if type == :has_many
177-
# parent.send(:"#{name}").push(child)
178-
# else
179-
# child.send(:"#{name}=", parent)
180-
# end
181-
# end
173+
# Delegates to #resource
182174
#
183175
# @see #name
184176
# @see #type
177+
# @api private
185178
def associate(parent, child)
186179
association_name = @parent ? @parent.name : name
187-
resource_class.config[:adapter].associate parent,
188-
child,
189-
association_name,
190-
type
180+
resource.associate(parent, child, association_name, type)
181+
end
182+
183+
# Configure how to disassociate parent and child records.
184+
# Delegates to #resource
185+
#
186+
# @see #name
187+
# @see #type
188+
# @api private
189+
def disassociate(parent, child)
190+
association_name = @parent ? @parent.name : name
191+
resource.disassociate(parent, child, association_name, type)
191192
end
192193

193194
# Define an attribute that groups the parent records. For instance, with

lib/jsonapi_compliable/util/persistence.rb

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ class JsonapiCompliable::Util::Persistence
55
# @param [Hash] meta see (Deserializer#meta)
66
# @param [Hash] attributes see (Deserializer#attributes)
77
# @param [Hash] relationships see (Deserializer#relationships)
8-
def initialize(resource, meta, attributes, relationships)
8+
def initialize(resource, meta, attributes, relationships, caller_model)
99
@resource = resource
1010
@meta = meta
1111
@attributes = attributes
1212
@relationships = relationships
13+
@caller_model = caller_model
1314
end
1415

1516
# Perform the actual save logic.
@@ -37,7 +38,7 @@ def run
3738
assign_temp_id(persisted, @meta[:temp_id])
3839
associate_parents(persisted, parents)
3940

40-
children = process_has_many(@relationships) do |x|
41+
children = process_has_many(@relationships, persisted) do |x|
4142
update_foreign_key(persisted, x[:attributes], x)
4243
end
4344

@@ -49,7 +50,11 @@ def run
4950

5051
# The child's attributes should be modified to nil-out the
5152
# foreign_key when the parent is being destroyed or disassociated
53+
#
54+
# This is not the case for HABTM, whose "foreign key" is a join table
5255
def update_foreign_key(parent_object, attrs, x)
56+
return if x[:sideload].type == :habtm
57+
5358
if [:destroy, :disassociate].include?(x[:meta][:method])
5459
attrs[x[:foreign_key]] = nil
5560
update_foreign_type(attrs, x, null: true) if x[:is_polymorphic]
@@ -72,33 +77,45 @@ def update_foreign_key_for_parents(parents)
7277

7378
def associate_parents(object, parents)
7479
parents.each do |x|
75-
x[:sideload].associate(x[:object], object) if x[:object] && object
80+
if x[:object] && object
81+
if x[:meta][:method] == :disassociate
82+
x[:sideload].disassociate(x[:object], object)
83+
else
84+
x[:sideload].associate(x[:object], object)
85+
end
86+
end
7687
end
7788
end
7889

7990
def associate_children(object, children)
8091
children.each do |x|
81-
x[:sideload].associate(object, x[:object]) if x[:object] && object
92+
if x[:object] && object
93+
if x[:meta][:method] == :disassociate
94+
x[:sideload].disassociate(object, x[:object])
95+
else
96+
x[:sideload].associate(object, x[:object])
97+
end
98+
end
8299
end
83100
end
84101

85102
def persist_object(method, attributes)
86103
case method
87104
when :destroy
88-
@resource.destroy(attributes[:id])
89-
when :disassociate, nil
90-
@resource.update(attributes)
105+
call_resource_method(:destroy, attributes[:id], @caller_model)
106+
when :update, nil, :disassociate
107+
call_resource_method(:update, attributes, @caller_model)
91108
else
92-
@resource.send(method, attributes)
109+
call_resource_method(:create, attributes, @caller_model)
93110
end
94111
end
95112

96-
def process_has_many(relationships)
113+
def process_has_many(relationships, caller_model)
97114
[].tap do |processed|
98115
iterate(except: [:polymorphic_belongs_to, :belongs_to]) do |x|
99116
yield x
100117
x[:object] = x[:sideload].resource
101-
.persist_with_relationships(x[:meta], x[:attributes], x[:relationships])
118+
.persist_with_relationships(x[:meta], x[:attributes], x[:relationships], caller_model)
102119
processed << x
103120
end
104121
end
@@ -128,4 +145,23 @@ def iterate(only: [], except: [])
128145
yield x
129146
end
130147
end
148+
149+
# In the Resource, we want to allow:
150+
#
151+
# def create(attrs)
152+
#
153+
# and
154+
#
155+
# def create(attrs, parent = nil)
156+
#
157+
# 'parent' is an optional parameter that should not be part of the
158+
# method signature in most use cases.
159+
def call_resource_method(method_name, attributes, caller_model)
160+
method = @resource.method(method_name)
161+
if [2,-2].include?(method.arity)
162+
method.call(attributes, caller_model)
163+
else
164+
method.call(attributes)
165+
end
166+
end
131167
end

spec/fixtures/employee_directory.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
t.string :address
1212
end
1313

14+
create_table :teams do |t|
15+
t.string :name
16+
end
17+
18+
create_table :employee_teams do |t|
19+
t.integer :team_id
20+
t.integer :employee_id
21+
end
22+
1423
create_table :employees do |t|
1524
t.string :workspace_type
1625
t.integer :workspace_id
@@ -40,6 +49,16 @@ class Classification < ApplicationRecord
4049
validates :description, presence: true
4150
end
4251

52+
class Team < ApplicationRecord
53+
has_many :employee_teams
54+
has_many :employees, through: :employee_teams
55+
end
56+
57+
class EmployeeTeam < ApplicationRecord
58+
belongs_to :team
59+
belongs_to :employee
60+
end
61+
4362
class Office < ApplicationRecord
4463
has_many :employees, as: :workspace
4564
end
@@ -53,6 +72,9 @@ class Employee < ApplicationRecord
5372
belongs_to :classification
5473
has_many :positions
5574
validates :first_name, presence: true
75+
76+
has_many :employee_teams
77+
has_many :teams, through: :employee_teams
5678
end
5779

5880
class Position < ApplicationRecord
@@ -73,6 +95,11 @@ class ClassificationResource < ApplicationResource
7395
model Classification
7496
end
7597

98+
class TeamResource < ApplicationResource
99+
type :teams
100+
model Team
101+
end
102+
76103
class DepartmentResource < ApplicationResource
77104
type :departments
78105
model Department
@@ -110,6 +137,11 @@ class EmployeeResource < ApplicationResource
110137
scope: -> { Position.all },
111138
foreign_key: :employee_id,
112139
resource: PositionResource
140+
has_and_belongs_to_many :teams,
141+
resource: TeamResource,
142+
scope: -> { Team.all },
143+
foreign_key: { employee_teams: :employee_id }
144+
113145
polymorphic_belongs_to :workspace,
114146
group_by: :workspace_type,
115147
groups: {
@@ -135,6 +167,12 @@ class SerializableClassification < SerializableAbstract
135167
attribute :description
136168
end
137169

170+
class SerializableTeam < SerializableAbstract
171+
type 'teams'
172+
173+
attribute :name
174+
end
175+
138176
class SerializableEmployee < SerializableAbstract
139177
type 'employees'
140178

@@ -144,6 +182,7 @@ class SerializableEmployee < SerializableAbstract
144182

145183
belongs_to :classification
146184
has_many :positions
185+
has_many :teams
147186
end
148187

149188
class SerializablePosition < SerializableAbstract

0 commit comments

Comments
 (0)