iknow_view_models 2.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ViewModel::ActiveRecord::Visitor
|
|
4
|
+
attr_reader :visit_shared, :for_edit
|
|
5
|
+
|
|
6
|
+
def initialize(visit_shared: true, for_edit: false)
|
|
7
|
+
@visit_shared = visit_shared
|
|
8
|
+
@for_edit = for_edit
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def visit(view, context: nil)
|
|
12
|
+
return unless pre_visit(view, context: context)
|
|
13
|
+
|
|
14
|
+
run_callback(ViewModel::Callbacks::Hook::BeforeVisit, view, context: context)
|
|
15
|
+
run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize, view, context: context) if for_edit
|
|
16
|
+
|
|
17
|
+
class_name = view.class.name.underscore.gsub('/', '__')
|
|
18
|
+
visit = :"visit_#{class_name}"
|
|
19
|
+
end_visit = :"end_visit_#{class_name}"
|
|
20
|
+
|
|
21
|
+
visit_children =
|
|
22
|
+
if respond_to?(visit, true)
|
|
23
|
+
self.send(visit, view, context: context)
|
|
24
|
+
else
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if visit_children
|
|
29
|
+
# visit the underlying viewmodel for each association, ignoring any
|
|
30
|
+
# customization
|
|
31
|
+
view.class._members.each do |name, member_data|
|
|
32
|
+
next unless member_data.is_a?(ViewModel::ActiveRecord::AssociationData)
|
|
33
|
+
next if member_data.shared? && !visit_shared
|
|
34
|
+
children = Array.wrap(view._read_association(name))
|
|
35
|
+
children.each do |child|
|
|
36
|
+
if context
|
|
37
|
+
child_context = view.context_for_child(name, context: context)
|
|
38
|
+
end
|
|
39
|
+
self.visit(child, context: child_context)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
self.send(end_visit, view, context: context) if respond_to?(end_visit, true)
|
|
45
|
+
|
|
46
|
+
if for_edit
|
|
47
|
+
view_changes = changes(view)
|
|
48
|
+
run_callback(ViewModel::Callbacks::Hook::OnChange, view, context: context, changes: view_changes) if view_changes.changed?
|
|
49
|
+
run_callback(ViewModel::Callbacks::Hook::AfterDeserialize, view, context: context, changes: view_changes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
run_callback(ViewModel::Callbacks::Hook::AfterVisit, view, context: context)
|
|
53
|
+
|
|
54
|
+
post_visit(view, context: context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Invoked for all view types before visit, may cancel visit by returning
|
|
58
|
+
# false.
|
|
59
|
+
def pre_visit(_view, context: nil)
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Invoked for all view types after visit.
|
|
64
|
+
def post_visit(_view, context: nil); end
|
|
65
|
+
|
|
66
|
+
# If a context is provided, run the specified callback hook on it
|
|
67
|
+
def run_callback(hook, view, context:, **args)
|
|
68
|
+
context.run_callback(hook, view, **args) if context
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# This method may be overridden by subclasses to specify the changes to be
|
|
72
|
+
# provided to callback hooks for each view. By default returns an empty
|
|
73
|
+
# Changes.
|
|
74
|
+
def changes(_view)
|
|
75
|
+
ViewModel::Changes.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module implementing the behaviour of a AR post-transaction hook. After calling
|
|
4
|
+
# `add_to_transaction`, the abstract method `after_transaction` will be invoked
|
|
5
|
+
# by AR's callbacks.
|
|
6
|
+
module ViewModel::AfterTransactionRunner
|
|
7
|
+
def committed!; end
|
|
8
|
+
|
|
9
|
+
def before_committed!
|
|
10
|
+
after_transaction
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def rolledback!(*)
|
|
14
|
+
after_transaction
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add_to_transaction
|
|
18
|
+
if connection.transaction_open?
|
|
19
|
+
connection.add_transaction_record(self)
|
|
20
|
+
else
|
|
21
|
+
after_transaction
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Override to tie to a specific connection.
|
|
26
|
+
def connection
|
|
27
|
+
ActiveRecord::Base.connection
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'renum'
|
|
4
|
+
require 'safe_values'
|
|
5
|
+
|
|
6
|
+
# Callback hooks for viewmodel traversal contexts
|
|
7
|
+
module ViewModel::Callbacks
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Callbacks are run in the instance context of an Env class that wraps the
|
|
11
|
+
# callbacks instance with additional instance method access to the view,
|
|
12
|
+
# context and extra context-dependent parameters.
|
|
13
|
+
module CallbackEnvContext
|
|
14
|
+
def method_missing(method, *args, &block)
|
|
15
|
+
if _callbacks.respond_to?(method, true)
|
|
16
|
+
_callbacks.send(method, *args, &block)
|
|
17
|
+
else
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def respond_to_missing?(method, include_all = false)
|
|
23
|
+
_callbacks.respond_to?(method, false) || super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Define the possible callback hooks and their required parameters.
|
|
28
|
+
enum :Hook do
|
|
29
|
+
BeforeVisit(:context)
|
|
30
|
+
AfterVisit(:context)
|
|
31
|
+
|
|
32
|
+
# The before deserialize hook is called when the viewmodel is visited during
|
|
33
|
+
# deserialization. At this point we don't know whether deserialization will
|
|
34
|
+
# be making any changes.
|
|
35
|
+
BeforeDeserialize(:deserialize_context)
|
|
36
|
+
|
|
37
|
+
# The BeforeValidate hook is called during deserialization immediately
|
|
38
|
+
# before validating the viewmodel. For AR viewmodels, this is after
|
|
39
|
+
# deserializing attributes and points-to associations, but before saving and
|
|
40
|
+
# deserializing points-from associations. Callbacks on this hook may make
|
|
41
|
+
# changes to the model, but must call the viewmodel's `*_changed!` methods
|
|
42
|
+
# for any changes to viewmodel attributes/associations.
|
|
43
|
+
BeforeValidate(:deserialize_context)
|
|
44
|
+
|
|
45
|
+
# The on change hook is called when deserialization has visited the model
|
|
46
|
+
# and made changes. Keyword argument `changes` is a ViewModel::Changes
|
|
47
|
+
# describing the effects. Callbacks on this hook may not themselves make any
|
|
48
|
+
# changes to the model. ViewModels backed by a transactional model such as
|
|
49
|
+
# AR will have been saved once, allowing the hook to inspect changed model
|
|
50
|
+
# values on `previous_changes`.
|
|
51
|
+
OnChange(:deserialize_context, :changes)
|
|
52
|
+
|
|
53
|
+
# The after-deserialize hook is called when leaving the viewmodel during
|
|
54
|
+
# deserialization. The recorded ViewModel::Changes instance (which may have
|
|
55
|
+
# no changes) is passed to the hook.
|
|
56
|
+
AfterDeserialize(:deserialize_context, :changes)
|
|
57
|
+
|
|
58
|
+
attr_reader :context_name, :required_params, :env_class
|
|
59
|
+
|
|
60
|
+
def init(context_name, *other_params)
|
|
61
|
+
@context_name = context_name
|
|
62
|
+
@required_params = other_params
|
|
63
|
+
@env_class = Value.new(:_callbacks, :view, context_name, *other_params) do
|
|
64
|
+
include CallbackEnvContext
|
|
65
|
+
delegate :model, to: :view
|
|
66
|
+
|
|
67
|
+
unless context_name == :context
|
|
68
|
+
alias_method :context, context_name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# If we have any other params, generate a combined positional/keyword
|
|
72
|
+
# constructor wrapper
|
|
73
|
+
if other_params.present?
|
|
74
|
+
params = other_params.map { |x| "#{x}:" }.join(', ')
|
|
75
|
+
args = other_params.join(', ')
|
|
76
|
+
instance_eval(<<-SRC, __FILE__, __LINE__ + 1)
|
|
77
|
+
def create(callbacks, view, context, #{params})
|
|
78
|
+
self.new(callbacks, view, context, #{args})
|
|
79
|
+
end
|
|
80
|
+
SRC
|
|
81
|
+
else
|
|
82
|
+
def self.create(callbacks, view, context)
|
|
83
|
+
self.new(callbacks, view, context)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dsl_add_hook_name
|
|
90
|
+
name.underscore
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dsl_viewmodel_callback_method
|
|
94
|
+
name.underscore.to_sym
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Placeholder for callbacks to invoked for all view types
|
|
99
|
+
ALWAYS = '__always'
|
|
100
|
+
|
|
101
|
+
# Callbacks classes may be inherited, including their callbacks and
|
|
102
|
+
# env method delegations.
|
|
103
|
+
included do
|
|
104
|
+
base_callbacks = {}
|
|
105
|
+
define_singleton_method(:class_callbacks) { base_callbacks }
|
|
106
|
+
define_singleton_method(:all_callbacks) do |&block|
|
|
107
|
+
return to_enum(__method__) unless block
|
|
108
|
+
block.call(base_callbacks)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class_methods do
|
|
113
|
+
def inherited(subclass)
|
|
114
|
+
subclass_callbacks = {}
|
|
115
|
+
subclass.define_singleton_method(:class_callbacks) { subclass_callbacks }
|
|
116
|
+
subclass.define_singleton_method(:all_callbacks) do |&block|
|
|
117
|
+
return to_enum(__method__) unless block
|
|
118
|
+
super(&block)
|
|
119
|
+
block.call(subclass_callbacks)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Add dsl methods to declare hooks in subclasses
|
|
124
|
+
Hook.each do |hook|
|
|
125
|
+
define_method(hook.dsl_add_hook_name) do |view_name = ALWAYS, &block|
|
|
126
|
+
add_callback(hook, view_name, &block)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def each_callback(hook, view_name)
|
|
131
|
+
valid_hook!(hook)
|
|
132
|
+
return to_enum(__method__, hook, view_name) unless block_given?
|
|
133
|
+
|
|
134
|
+
all_callbacks do |callbacks|
|
|
135
|
+
if (hook_callbacks = callbacks[hook])
|
|
136
|
+
hook_callbacks[view_name.to_s]&.each { |c| yield(c) }
|
|
137
|
+
hook_callbacks[ALWAYS]&.each { |c| yield(c) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def updates_view?
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def updates_view!
|
|
149
|
+
define_singleton_method(:updates_view?) { true }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_callback(hook, view_name, &block)
|
|
153
|
+
valid_hook!(hook)
|
|
154
|
+
|
|
155
|
+
hook_callbacks = (class_callbacks[hook] ||= {})
|
|
156
|
+
view_callbacks = (hook_callbacks[view_name.to_s] ||= [])
|
|
157
|
+
view_callbacks << block
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def valid_hook!(hook)
|
|
161
|
+
unless hook.is_a?(Hook)
|
|
162
|
+
raise ArgumentError.new("Invalid hook: '#{hook}'")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def run_callback(hook, view, context, **args)
|
|
168
|
+
return if ineligible(view)
|
|
169
|
+
|
|
170
|
+
callback_env = hook.env_class.create(self, view, context, **args)
|
|
171
|
+
|
|
172
|
+
view_name = view.class.view_name
|
|
173
|
+
self.class.each_callback(hook, view_name) do |callback|
|
|
174
|
+
callback_env.instance_exec(&callback)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ineligible(view)
|
|
179
|
+
# ARVM synthetic views are considered part of their association and as such
|
|
180
|
+
# are not visited by callbacks. Eligibility exclusion is intended to be
|
|
181
|
+
# library-internal: subclasses should not attempt to extend this.
|
|
182
|
+
view.is_a?(ViewModel::ActiveRecord) && view.class.synthetic
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.wrap_serialize(viewmodel, context:)
|
|
186
|
+
context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, viewmodel)
|
|
187
|
+
val = yield
|
|
188
|
+
context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, viewmodel)
|
|
189
|
+
val
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Record changes made in the deserialization block so that they can be
|
|
193
|
+
# provided to the AfterDeserialize hook.
|
|
194
|
+
DeserializeHookControl = Struct.new(:changes) do
|
|
195
|
+
alias_method :record_changes, :changes=
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def self.wrap_deserialize(viewmodel, deserialize_context:)
|
|
199
|
+
hook_control = DeserializeHookControl.new
|
|
200
|
+
|
|
201
|
+
wrap_serialize(viewmodel, context: deserialize_context) do
|
|
202
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize,
|
|
203
|
+
viewmodel)
|
|
204
|
+
|
|
205
|
+
val = yield(hook_control)
|
|
206
|
+
|
|
207
|
+
if hook_control.changes.nil?
|
|
208
|
+
raise ViewModel::DeserializationError::Internal.new(
|
|
209
|
+
'Internal error: changes not recorded for deserialization of viewmodel',
|
|
210
|
+
viewmodel.blame_reference)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::AfterDeserialize,
|
|
214
|
+
viewmodel,
|
|
215
|
+
changes: hook_control.changes)
|
|
216
|
+
val
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'view_model/utils/collections'
|
|
4
|
+
|
|
5
|
+
class ViewModel::Changes
|
|
6
|
+
using ViewModel::Utils::Collections
|
|
7
|
+
|
|
8
|
+
attr_reader :new, :changed_attributes, :changed_associations, :changed_children, :deleted
|
|
9
|
+
|
|
10
|
+
alias new? new
|
|
11
|
+
alias deleted? deleted
|
|
12
|
+
alias changed_children? changed_children
|
|
13
|
+
|
|
14
|
+
def initialize(new: false, changed_attributes: [], changed_associations: [], changed_children: false, deleted: false)
|
|
15
|
+
@new = new
|
|
16
|
+
@changed_attributes = changed_attributes.map(&:to_s)
|
|
17
|
+
@changed_associations = changed_associations.map(&:to_s)
|
|
18
|
+
@changed_children = changed_children
|
|
19
|
+
@deleted = deleted
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contained_to?(associations: [], attributes: [])
|
|
23
|
+
!deleted? &&
|
|
24
|
+
changed_associations.all? { |assoc| associations.include?(assoc.to_s) } &&
|
|
25
|
+
changed_attributes.all? { |attr| attributes.include?(attr.to_s) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def changed_any?(associations: [], attributes: [])
|
|
29
|
+
associations.any? { |assoc| changed_associations.include?(assoc.to_s) } ||
|
|
30
|
+
attributes.any? { |attr| changed_attributes.include?(attr.to_s) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def changed?
|
|
34
|
+
new? || deleted? || changed_attributes.present? || changed_associations.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def changed_tree?
|
|
38
|
+
changed? || changed_children?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
'changed_attributes' => changed_attributes.dup,
|
|
44
|
+
'changed_associations' => changed_associations.dup,
|
|
45
|
+
'new' => new?,
|
|
46
|
+
'changed_children' => changed_children?,
|
|
47
|
+
'deleted' => deleted?,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ==(other)
|
|
52
|
+
return false unless other.is_a?(ViewModel::Changes)
|
|
53
|
+
|
|
54
|
+
self.new? == other.new? &&
|
|
55
|
+
self.changed_children? == other.changed_children? &&
|
|
56
|
+
self.deleted? == other.deleted? &&
|
|
57
|
+
self.changed_attributes.contains_exactly?(other.changed_attributes) &&
|
|
58
|
+
self.changed_associations.contains_exactly?(other.changed_associations)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
alias eql? ==
|
|
62
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'safe_values'
|
|
4
|
+
require 'keyword_builder'
|
|
5
|
+
|
|
6
|
+
ViewModel::Config = Value.new(
|
|
7
|
+
show_cause_in_error_view: false,
|
|
8
|
+
debug_deserialization: false,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
class ViewModel::Config
|
|
12
|
+
def self.configure!(&block)
|
|
13
|
+
if instance_variable_defined?(:@instance)
|
|
14
|
+
raise ArgumentError.new('ViewModel library already configured')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
builder = KeywordBuilder.create(self, constructor: :with)
|
|
18
|
+
@instance = builder.build!(&block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self._option(opt)
|
|
22
|
+
configure! unless instance_variable_defined?(:@instance)
|
|
23
|
+
@instance[opt]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
self.members.each do |opt|
|
|
27
|
+
define_singleton_method(opt) { _option(opt) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "view_model"
|
|
4
|
+
|
|
5
|
+
module ViewModel::Controller
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
rescue_from ViewModel::AbstractError, with: ->(ex) do
|
|
10
|
+
render_error(ex.view, ex.status)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render_viewmodel(viewmodel, status: nil, serialize_context: viewmodel.class.try(:new_serialize_context), &block)
|
|
15
|
+
prerender = prerender_viewmodel(viewmodel, serialize_context: serialize_context, &block)
|
|
16
|
+
render_json_string(prerender, status: status)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Render viewmodel(s) to a JSON API response as a String
|
|
20
|
+
def prerender_viewmodel(viewmodel, status: nil, serialize_context: viewmodel.class.try(:new_serialize_context))
|
|
21
|
+
Jbuilder.encode do |json|
|
|
22
|
+
json.data do
|
|
23
|
+
ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if serialize_context && serialize_context.has_references?
|
|
27
|
+
json.references do
|
|
28
|
+
serialize_context.serialize_references(json)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
yield(json) if block_given?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Render an arbitrarily nested tree of hashes and arrays with pre-rendered
|
|
37
|
+
# JSON string terminals. Useful for rendering cached views without parsing
|
|
38
|
+
# then re-serializing the cached JSON.
|
|
39
|
+
def render_json_view(json_view, json_references: {}, status: nil, &block)
|
|
40
|
+
prerender = prerender_json_view(json_view, json_references: json_references, &block)
|
|
41
|
+
render_json_string(prerender, status: status)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def prerender_json_view(json_view, json_references: {})
|
|
45
|
+
json_view = wrap_json_view(json_view)
|
|
46
|
+
json_references = wrap_json_view(json_references)
|
|
47
|
+
|
|
48
|
+
Jbuilder.encode do |json|
|
|
49
|
+
json.data json_view
|
|
50
|
+
if json_references.present?
|
|
51
|
+
json.references do
|
|
52
|
+
json_references.sort.each do |key, value|
|
|
53
|
+
json.set!(key, value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
yield(json) if block_given?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_error(error_view, status = 500)
|
|
62
|
+
unless error_view.is_a?(ViewModel)
|
|
63
|
+
raise "Expected ViewModel error view, received #{error_view.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
render_jbuilder(status: status) do |json|
|
|
67
|
+
json.error do
|
|
68
|
+
ctx = error_view.class.new_serialize_context(access_control: ViewModel::AccessControl::Open.new)
|
|
69
|
+
ViewModel.serialize(error_view, json, serialize_context: ctx)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
protected
|
|
75
|
+
|
|
76
|
+
def parse_viewmodel_updates
|
|
77
|
+
update_hash = _extract_update_data(params.fetch(:data))
|
|
78
|
+
refs = _extract_param_hash(params.fetch(:references, {}))
|
|
79
|
+
|
|
80
|
+
return update_hash, refs
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def _extract_update_data(data)
|
|
86
|
+
if data.is_a?(Array)
|
|
87
|
+
if data.blank?
|
|
88
|
+
raise ViewModel::Error.new(status: 400, detail: "No data submitted: #{data.inspect}")
|
|
89
|
+
end
|
|
90
|
+
data.map { |el| _extract_param_hash(el) }
|
|
91
|
+
else
|
|
92
|
+
_extract_param_hash(data)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def _extract_param_hash(data)
|
|
97
|
+
case data
|
|
98
|
+
when Hash
|
|
99
|
+
data
|
|
100
|
+
when ActionController::Parameters
|
|
101
|
+
data.to_unsafe_h
|
|
102
|
+
else
|
|
103
|
+
raise ViewModel::Error.new(status: 400, detail: "Invalid data submitted, expected hash: #{data.inspect}")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_jbuilder(status:)
|
|
108
|
+
response = Jbuilder.encode do |json|
|
|
109
|
+
yield json
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
render_json_string(response, status: status)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_json_string(response, status: nil)
|
|
116
|
+
render(json: response, status: status)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Wrap raw JSON in such a way that MultiJSON knows to pass it through
|
|
120
|
+
# untouched. Requires a MultiJson adapter other than ActiveSupport's
|
|
121
|
+
# (modified) JsonGem.
|
|
122
|
+
class CompiledJson
|
|
123
|
+
def initialize(s); @s = s; end
|
|
124
|
+
def to_json(*args); @s; end
|
|
125
|
+
def to_s; @s; end
|
|
126
|
+
undef_method :as_json
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Traverse a tree and wrap all String terminals in CompiledJson
|
|
130
|
+
def wrap_json_view(view)
|
|
131
|
+
case view
|
|
132
|
+
when Array
|
|
133
|
+
view.map { |v| wrap_json_view(v) }
|
|
134
|
+
when Hash
|
|
135
|
+
view.transform_values { |v| wrap_json_view(v) }
|
|
136
|
+
when String, Symbol
|
|
137
|
+
CompiledJson.new(view)
|
|
138
|
+
else
|
|
139
|
+
view
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|