Skip to content

Commit 2294de7

Browse files
authored
Merge pull request #52 from richmolj/hooks_clean
Add post-processing hooks
2 parents bb21767 + 8c67587 commit 2294de7

File tree

4 files changed

+323
-4
lines changed

4 files changed

+323
-4
lines changed

lib/jsonapi_compliable/adapters/active_record_sideloading.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ def belongs_to(association_name, scope: nil, resource:, foreign_key:, primary_ke
3939
parent.send(:"#{association_name}=", relevant_child)
4040
end
4141
end
42-
end
4342

44-
instance_eval(&blk) if blk
43+
instance_eval(&blk) if blk
44+
end
4545
end
4646

4747
def has_one(association_name, scope: nil, resource:, foreign_key:, primary_key: :id, &blk)

lib/jsonapi_compliable/sideload.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,50 @@ def disassociate(parent, child)
191191
resource.disassociate(parent, child, association_name, type)
192192
end
193193

194+
HOOK_ACTIONS = [:save, :create, :update, :destroy, :disassociate]
195+
196+
# Configure post-processing hooks
197+
#
198+
# In particular, helpful for bulk operations. "after_save" will fire
199+
# for any persistence method - +:create+, +:update+, +:destroy+, +:disassociate+.
200+
# Use "only" and "except" keyword arguments to fire only for a
201+
# specific persistence method.
202+
#
203+
# @example Bulk Notify Users on Invite
204+
# class ProjectResource < ApplicationResource
205+
# # ... code ...
206+
# allow_sideload :users, resource: UserResource do
207+
# # scope {}
208+
# # assign {}
209+
# after_save only: [:create] do |project, users|
210+
# UserMailer.invite(project, users).deliver_later
211+
# end
212+
# end
213+
# end
214+
#
215+
# @see #hooks
216+
# @see Util::Persistence
217+
def after_save(only: [], except: [], &blk)
218+
actions = HOOK_ACTIONS - except
219+
actions = only & actions
220+
actions = [:save] if only.empty? && except.empty?
221+
actions.each do |a|
222+
hooks[:"after_#{a}"] << blk
223+
end
224+
end
225+
226+
# Get the hooks the user has configured
227+
# @see #after_save
228+
# @return hash of hooks, ie +{ after_create: #<Proc>}+
229+
def hooks
230+
@hooks ||= {}.tap do |h|
231+
HOOK_ACTIONS.each do |a|
232+
h[:"after_#{a}"] = []
233+
h[:"before_#{a}"] = []
234+
end
235+
end
236+
end
237+
194238
# Define an attribute that groups the parent records. For instance, with
195239
# an ActiveRecord polymorphic belongs_to there will be a +parent_id+
196240
# and +parent_type+. We would want to group on +parent_type+:
@@ -340,6 +384,15 @@ def polymorphic_child_for_type(type)
340384
end
341385
end
342386

387+
def fire_hooks!(parent, objects, method)
388+
return unless self.hooks
389+
390+
hooks = self.hooks[:"after_#{method}"] + self.hooks[:after_save]
391+
hooks.compact.each do |hook|
392+
resource.instance_exec(parent, objects, &hook)
393+
end
394+
end
395+
343396
private
344397

345398
def nested_sideload_hash(sideload, processed)

lib/jsonapi_compliable/util/persistence.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def initialize(resource, meta, attributes, relationships, caller_model)
2727
# * associate parent objects with current object
2828
# * process children
2929
# * associate children
30+
# * run post-process sideload hooks
3031
# * return current object
3132
#
3233
# @return the persisted model instance
@@ -36,14 +37,17 @@ def run
3637

3738
persisted = persist_object(@meta[:method], @attributes)
3839
assign_temp_id(persisted, @meta[:temp_id])
40+
3941
associate_parents(persisted, parents)
4042

4143
children = process_has_many(@relationships, persisted) do |x|
4244
update_foreign_key(persisted, x[:attributes], x)
4345
end
4446

45-
associate_children(persisted, children)
46-
persisted unless @meta[:method] == :destroy
47+
associate_children(persisted, children) unless @meta[:method] == :destroy
48+
post_process(persisted, parents)
49+
post_process(persisted, children)
50+
persisted
4751
end
4852

4953
private
@@ -76,6 +80,9 @@ def update_foreign_key_for_parents(parents)
7680
end
7781

7882
def associate_parents(object, parents)
83+
# No need to associate to destroyed objects
84+
parents = parents.select { |x| x[:meta][:method] != :destroy }
85+
7986
parents.each do |x|
8087
if x[:object] && object
8188
if x[:meta][:method] == :disassociate
@@ -88,6 +95,9 @@ def associate_parents(object, parents)
8895
end
8996

9097
def associate_children(object, children)
98+
# No need to associate destroyed objects
99+
return if @meta[:method] == :destroy
100+
91101
children.each do |x|
92102
if x[:object] && object
93103
if x[:meta][:method] == :disassociate
@@ -131,6 +141,16 @@ def process_belongs_to(relationships)
131141
end
132142
end
133143

144+
def post_process(caller_model, processed)
145+
groups = processed.group_by { |x| x[:meta][:method] }
146+
groups.each_pair do |method, group|
147+
group.group_by { |g| g[:sideload] }.each_pair do |sideload, members|
148+
objects = members.map { |x| x[:object] }
149+
sideload.fire_hooks!(caller_model, objects, method)
150+
end
151+
end
152+
end
153+
134154
def assign_temp_id(object, temp_id)
135155
object.instance_variable_set(:@_jsonapi_temp_id, temp_id)
136156
end

spec/integration/rails/hooks_spec.rb

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
if ENV["APPRAISAL_INITIALIZED"]
2+
require 'rails_spec_helper'
3+
4+
RSpec.describe 'sideload lifecycle hooks', type: :controller do
5+
class Callbacks
6+
def self.fired
7+
@fired
8+
end
9+
10+
def self.fired=(val)
11+
@fired = val
12+
end
13+
end
14+
15+
before do
16+
Callbacks.fired = {}
17+
end
18+
19+
module IntegrationHooks
20+
class BookResource < JsonapiCompliable::Resource
21+
type :books
22+
use_adapter JsonapiCompliable::Adapters::ActiveRecord
23+
model Book
24+
end
25+
26+
class StateResource < JsonapiCompliable::Resource
27+
type :states
28+
use_adapter JsonapiCompliable::Adapters::ActiveRecord
29+
model State
30+
end
31+
32+
class AuthorResource < JsonapiCompliable::Resource
33+
type :authors
34+
use_adapter JsonapiCompliable::Adapters::ActiveRecord
35+
model Author
36+
37+
has_many :books,
38+
foreign_key: :author_id,
39+
scope: -> { Book.all },
40+
resource: BookResource do
41+
after_save only: [:create] do |author, books|
42+
Callbacks.fired[:after_create] = [author, books]
43+
end
44+
45+
after_save only: [:update] do |author, books|
46+
Callbacks.fired[:after_update] = [author, books]
47+
end
48+
49+
after_save only: [:destroy] do |author, books|
50+
Callbacks.fired[:after_destroy] = [author, books]
51+
end
52+
53+
after_save only: [:disassociate] do |author, books|
54+
Callbacks.fired[:after_disassociate] = [author, books]
55+
end
56+
57+
after_save do |author, books|
58+
Callbacks.fired[:after_save] = [author, books]
59+
end
60+
end
61+
62+
belongs_to :state,
63+
foreign_key: :state_id,
64+
scope: -> { State.all },
65+
resource: StateResource do
66+
after_save only: [:create] do |author, states|
67+
Callbacks.fired[:state_after_create] = [author, states]
68+
end
69+
end
70+
end
71+
end
72+
73+
controller(ApplicationController) do
74+
jsonapi resource: IntegrationHooks::AuthorResource
75+
76+
def create
77+
author, success = jsonapi_create.to_a
78+
79+
if success
80+
render_jsonapi(author, scope: false)
81+
else
82+
raise 'whoops'
83+
end
84+
end
85+
86+
private
87+
88+
def params
89+
@params ||= begin
90+
hash = super.to_unsafe_h.with_indifferent_access
91+
hash = hash[:params] if hash.has_key?(:params)
92+
hash
93+
end
94+
end
95+
end
96+
97+
before do
98+
@request.headers['Accept'] = Mime[:json]
99+
@request.headers['Content-Type'] = Mime[:json].to_s
100+
101+
routes.draw {
102+
post "create" => "anonymous#create"
103+
}
104+
end
105+
106+
def json
107+
JSON.parse(response.body)
108+
end
109+
110+
let(:update_book) { Book.create! }
111+
let(:destroy_book) { Book.create! }
112+
let(:disassociate_book) { Book.create! }
113+
114+
let(:book_data) { [] }
115+
let(:book_included) { [] }
116+
let(:state_data) { {} }
117+
let(:state_included) { [] }
118+
119+
let(:payload) do
120+
{
121+
data: {
122+
type: 'authors',
123+
attributes: { first_name: 'Stephen', last_name: 'King' },
124+
relationships: {
125+
books: { data: book_data },
126+
state: { data: state_data }
127+
}
128+
},
129+
included: (book_included + state_included)
130+
}
131+
end
132+
133+
context 'after_save' do
134+
before do
135+
book_data << { :'temp-id' => 'abc123', type: 'books', method: 'create' }
136+
book_included << { :'temp-id' => 'abc123', type: 'books', attributes: { title: 'one' } }
137+
book_data << { id: update_book.id.to_s, type: 'books', method: 'update' }
138+
book_included << { id: update_book.id.to_s, type: 'books', attributes: { title: 'updated!' } }
139+
end
140+
end
141+
142+
context 'after_create' do
143+
before do
144+
book_data << { :'temp-id' => 'abc123', type: 'books', method: 'create' }
145+
book_included << { :'temp-id' => 'abc123', type: 'books', attributes: { title: 'one' } }
146+
book_data << { :'temp-id' => 'abc456', type: 'books', method: 'create' }
147+
book_included << { :'temp-id' => 'abc456', type: 'books', attributes: { title: 'two' } }
148+
end
149+
150+
it 'fires hooks correctly' do
151+
post :create, params: payload
152+
153+
expect(Callbacks.fired.keys).to match_array([:after_create, :after_save])
154+
author, books = Callbacks.fired[:after_create]
155+
expect(author).to be_a(Author)
156+
expect(author.first_name).to eq('Stephen')
157+
expect(author.last_name).to eq('King')
158+
159+
expect(books).to all(be_a(Book))
160+
expect(books.map(&:title)).to match_array(%w(one two))
161+
end
162+
end
163+
164+
context 'after_update' do
165+
before do
166+
book_data << { id: update_book.id.to_s, type: 'books', method: 'update' }
167+
book_included << { id: update_book.id.to_s, type: 'books', attributes: { title: 'updated!' } }
168+
end
169+
170+
it 'fires hooks correctly' do
171+
post :create, params: payload
172+
173+
expect(Callbacks.fired.keys)
174+
.to match_array([:after_update, :after_save])
175+
author, books = Callbacks.fired[:after_update]
176+
expect(author).to be_a(Author)
177+
expect(author.first_name).to eq('Stephen')
178+
expect(author.last_name).to eq('King')
179+
180+
book = books[0]
181+
expect(book.title).to eq('updated!')
182+
end
183+
end
184+
185+
context 'after_destroy' do
186+
before do
187+
book_data << { id: destroy_book.id.to_s, type: 'books', method: 'destroy' }
188+
end
189+
190+
it 'fires hooks correctly' do
191+
post :create, params: payload
192+
193+
expect(Callbacks.fired.keys).to match_array([:after_destroy, :after_save])
194+
author, books = Callbacks.fired[:after_destroy]
195+
expect(author).to be_a(Author)
196+
expect(author.first_name).to eq('Stephen')
197+
expect(author.last_name).to eq('King')
198+
199+
book = books[0]
200+
expect(book).to be_a(Book)
201+
expect(book.id).to eq(destroy_book.id)
202+
expect { book.reload }.to raise_error(ActiveRecord::RecordNotFound)
203+
end
204+
end
205+
206+
context 'after_disassociate' do
207+
before do
208+
book_data << { id: disassociate_book.id.to_s, type: 'books', method: 'disassociate' }
209+
end
210+
211+
it 'fires hooks correctly' do
212+
post :create, params: payload
213+
214+
expect(Callbacks.fired.keys).to match_array([:after_disassociate, :after_save])
215+
author, books = Callbacks.fired[:after_disassociate]
216+
expect(author).to be_a(Author)
217+
expect(author.first_name).to eq('Stephen')
218+
expect(author.last_name).to eq('King')
219+
220+
book = books[0]
221+
expect(book).to be_a(Book)
222+
expect(book.id).to eq(disassociate_book.id)
223+
expect(book.author_id).to be_nil
224+
end
225+
end
226+
227+
context 'belongs_to' do
228+
before do
229+
state_data.merge!(:'temp-id' => 'abc123', type: 'states', method: 'create')
230+
state_included << { :'temp-id' => 'abc123', type: 'states', attributes: { name: 'New York' } }
231+
end
232+
233+
it 'also works' do
234+
post :create, params: payload
235+
expect(Callbacks.fired.keys).to match_array([:state_after_create])
236+
author, states = Callbacks.fired[:state_after_create]
237+
state = states[0]
238+
expect(author).to be_a(Author)
239+
expect(author.first_name).to eq('Stephen')
240+
expect(author.last_name).to eq('King')
241+
expect(state).to be_a(State)
242+
expect(state.name).to eq('New York')
243+
end
244+
end
245+
end
246+
end

0 commit comments

Comments
 (0)