Skip to content

Commit 387df5d

Browse files
author
Lee Richmond
committed
Add before_commit hooks
These hooks run after validating the whole graph, but before closing the transaction. Helpful for things like "contact this service after saving, but rollback if the service is down". Moves the existing sideload hooks to the same place (the previous behavior was to fire before validations). Implemented by a Hook accumulator that uses Thread.current. I've tried this a few different ways but recursive functions that return a mash of objects seem to add a lot of complexity to the code for no real reason. The premise of registering hooks during a complex process, then calling those hooks later, is simpler. This does add a small amount of duplication between the create/update actions and the destroy action. This is because we're currently not supporting DELETE requests with a body (nested deletes) so the processing logic is different. Think this can be assimliated in a separate PR.
1 parent 91cf48e commit 387df5d

File tree

10 files changed

+420
-27
lines changed

10 files changed

+420
-27
lines changed

lib/jsonapi_compliable.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
require "jsonapi_compliable/util/persistence"
2525
require "jsonapi_compliable/util/validation_response"
2626
require "jsonapi_compliable/util/sideload"
27+
require "jsonapi_compliable/util/hooks"
2728

2829
# require correct jsonapi-rb before extensions
2930
if defined?(Rails)

lib/jsonapi_compliable/base.rb

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,20 @@ def jsonapi_update
246246
end
247247
end
248248

249+
# Delete the model
250+
# Any error, including validation errors, will roll back the transaction.
251+
#
252+
# Note: +before_commit+ hooks still run unless excluded
253+
#
254+
# @return [Util::ValidationResponse]
249255
def jsonapi_destroy
250-
_persist do
251-
jsonapi_resource.destroy(params[:id])
256+
jsonapi_resource.transaction do
257+
model = jsonapi_resource.destroy(params[:id])
258+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
259+
model, deserialized_params
260+
validator.validate!
261+
jsonapi_resource.before_commit(model, :destroy)
262+
validator
252263
end
253264
end
254265

@@ -314,6 +325,18 @@ def default_jsonapi_render_options
314325

315326
private
316327

328+
def _persist
329+
jsonapi_resource.transaction do
330+
::JsonapiCompliable::Util::Hooks.record do
331+
model = yield
332+
validator = ::JsonapiCompliable::Util::ValidationResponse.new \
333+
model, deserialized_params
334+
validator.validate!
335+
validator
336+
end
337+
end
338+
end
339+
317340
def force_includes?
318341
not deserialized_params.data.nil?
319342
end
@@ -323,16 +346,5 @@ def perform_render_jsonapi(opts)
323346
JSONAPI::Serializable::Renderer.new
324347
.render(opts.delete(:jsonapi), opts).to_json
325348
end
326-
327-
def _persist
328-
validation_response = nil
329-
jsonapi_resource.transaction do
330-
object = yield
331-
validation_response = Util::ValidationResponse.new \
332-
object, deserialized_params
333-
raise Errors::ValidationError unless validation_response.to_a[1]
334-
end
335-
validation_response
336-
end
337349
end
338350
end

lib/jsonapi_compliable/errors.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
module JsonapiCompliable
22
module Errors
33
class BadFilter < StandardError; end
4-
class ValidationError < StandardError; end
4+
5+
class ValidationError < StandardError
6+
attr_reader :validation_response
7+
8+
def initialize(validation_response)
9+
@validation_response = validation_response
10+
end
11+
end
512

613
class UnsupportedPageSize < StandardError
714
def initialize(size, max)

lib/jsonapi_compliable/resource.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,29 @@ def self.model(klass)
257257
config[:model] = klass
258258
end
259259

260+
# Register a hook that fires AFTER all validation logic has run -
261+
# including validation of nested objects - but BEFORE the transaction
262+
# has closed.
263+
#
264+
# Helpful for things like "contact this external service after persisting
265+
# data, but roll everything back if there's an error making the service call"
266+
#
267+
# @param [Hash] +only: [:create, :update, :destroy]+
268+
def self.before_commit(only: [:create, :update, :destroy], &blk)
269+
Array(only).each do |verb|
270+
config[:before_commit][verb] = blk
271+
end
272+
end
273+
274+
# Actually fire the before commit hooks
275+
#
276+
# @see .before_commit
277+
# @api private
278+
def before_commit(model, method)
279+
hook = self.class.config[:before_commit][method]
280+
hook.call(model) if hook
281+
end
282+
260283
# Define custom sorting logic
261284
#
262285
# @example Sort on alternate table
@@ -391,6 +414,7 @@ def self.config
391414
sorting: nil,
392415
pagination: nil,
393416
model: nil,
417+
before_commit: {},
394418
adapter: Adapters::Abstract.new
395419
}
396420
end
@@ -705,7 +729,8 @@ def transaction
705729
adapter.transaction(model) do
706730
response = yield
707731
end
708-
rescue Errors::ValidationError
732+
rescue Errors::ValidationError => e
733+
response = e.validation_response
709734
end
710735
response
711736
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module JsonapiCompliable
2+
module Util
3+
class Hooks
4+
def self.record
5+
self.hooks = []
6+
begin
7+
yield.tap { run }
8+
ensure
9+
self.hooks = []
10+
end
11+
end
12+
13+
def self._hooks
14+
Thread.current[:_compliable_hooks] ||= []
15+
end
16+
private_class_method :_hooks
17+
18+
def self.hooks=(val)
19+
Thread.current[:_compliable_hooks] = val
20+
end
21+
22+
# Because hooks will be added from the outer edges of
23+
# the graph, working inwards
24+
def self.add(prc)
25+
_hooks.unshift(prc)
26+
end
27+
28+
def self.run
29+
_hooks.each { |h| h.call }
30+
end
31+
end
32+
end
33+
end

lib/jsonapi_compliable/util/persistence.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ def initialize(resource, meta, attributes, relationships, caller_model)
2727
# * associate parent objects with current object
2828
# * process children
2929
# * associate children
30+
# * record hooks for later playback
3031
# * run post-process sideload hooks
3132
# * return current object
3233
#
33-
# @return the persisted model instance
34+
# @return a model instance
3435
def run
3536
parents = process_belongs_to(@relationships)
3637
update_foreign_key_for_parents(parents)
@@ -45,13 +46,20 @@ def run
4546
end
4647

4748
associate_children(persisted, children) unless @meta[:method] == :destroy
49+
4850
post_process(persisted, parents)
4951
post_process(persisted, children)
52+
before_commit = -> { @resource.before_commit(persisted, @meta[:method]) }
53+
add_hook(before_commit)
5054
persisted
5155
end
5256

5357
private
5458

59+
def add_hook(prc)
60+
::JsonapiCompliable::Util::Hooks.add(prc)
61+
end
62+
5563
# The child's attributes should be modified to nil-out the
5664
# foreign_key when the parent is being destroyed or disassociated
5765
#
@@ -147,7 +155,8 @@ def post_process(caller_model, processed)
147155
groups.each_pair do |method, group|
148156
group.group_by { |g| g[:sideload] }.each_pair do |sideload, members|
149157
objects = members.map { |x| x[:object] }
150-
sideload.fire_hooks!(caller_model, objects, method)
158+
hook = -> { sideload.fire_hooks!(caller_model, objects, method) }
159+
add_hook(hook)
151160
end
152161
end
153162
end

lib/jsonapi_compliable/util/validation_response.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ def to_a
3030
[object, success?]
3131
end
3232

33+
def validate!
34+
unless success?
35+
raise ::JsonapiCompliable::Errors::ValidationError.new(self)
36+
end
37+
self
38+
end
39+
3340
private
3441

3542
def valid_object?(object)

spec/fixtures/employee_directory.rb

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@
4848

4949
class ApplicationRecord < ActiveRecord::Base
5050
self.abstract_class = true
51+
attr_accessor :force_validation_error
52+
53+
before_save do
54+
add_validation_error if force_validation_error
55+
56+
if Rails::VERSION::MAJOR >= 5
57+
throw(:abort) if errors.present?
58+
else
59+
errors.blank?
60+
end
61+
end
62+
63+
def add_validation_error
64+
errors.add(:base, 'Forced validation error')
65+
end
5166
end
5267

5368
class Classification < ApplicationRecord
@@ -74,8 +89,6 @@ class HomeOffice < ApplicationRecord
7489
end
7590

7691
class Employee < ApplicationRecord
77-
attr_accessor :force_validation_error
78-
7992
belongs_to :workspace, polymorphic: true
8093
belongs_to :classification
8194
has_many :positions
@@ -98,10 +111,6 @@ class Employee < ApplicationRecord
98111
errors.blank?
99112
end
100113
end
101-
102-
def add_validation_error
103-
errors.add(:base, 'Forced validation error')
104-
end
105114
end
106115

107116
class Position < ApplicationRecord

0 commit comments

Comments
 (0)