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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'view_model/active_record/controller_base'
|
|
4
|
+
|
|
5
|
+
# Controller mixin defining machinery for accessing viewmodels nested under a
|
|
6
|
+
# parent. Used by Singular- and CollectionNestedControllers
|
|
7
|
+
module ViewModel::ActiveRecord::NestedControllerBase
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def show_association(scope: nil, serialize_context: new_serialize_context)
|
|
13
|
+
associated_views = nil
|
|
14
|
+
pre_rendered = owner_viewmodel.transaction do
|
|
15
|
+
owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
|
|
16
|
+
ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
|
|
17
|
+
# Association manipulation methods construct child contexts internally
|
|
18
|
+
associated_views = owner_view.load_associated(association_name, scope: scope, serialize_context: serialize_context)
|
|
19
|
+
|
|
20
|
+
associated_views = yield(associated_views) if block_given?
|
|
21
|
+
|
|
22
|
+
child_context = owner_view.context_for_child(association_name, context: serialize_context)
|
|
23
|
+
prerender_viewmodel(associated_views, serialize_context: child_context)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
render_json_string(pre_rendered)
|
|
27
|
+
associated_views
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write_association(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
|
|
31
|
+
association_view = nil
|
|
32
|
+
pre_rendered = owner_viewmodel.transaction do
|
|
33
|
+
update_hash, refs = parse_viewmodel_updates
|
|
34
|
+
|
|
35
|
+
owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
|
|
36
|
+
|
|
37
|
+
association_view = owner_view.replace_associated(association_name, update_hash,
|
|
38
|
+
references: refs,
|
|
39
|
+
deserialize_context: deserialize_context)
|
|
40
|
+
|
|
41
|
+
ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
|
|
42
|
+
child_context = owner_view.context_for_child(association_name, context: serialize_context)
|
|
43
|
+
ViewModel.preload_for_serialization(association_view, serialize_context: child_context)
|
|
44
|
+
prerender_viewmodel(association_view, serialize_context: child_context)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
render_json_string(pre_rendered)
|
|
48
|
+
association_view
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy_association(collection, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
|
|
52
|
+
empty_update = collection ? [] : nil
|
|
53
|
+
owner_viewmodel.deserialize_from_view(owner_update_hash(empty_update),
|
|
54
|
+
deserialize_context: deserialize_context)
|
|
55
|
+
render_viewmodel(empty_update, serialize_context: serialize_context)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def association_data
|
|
59
|
+
owner_viewmodel._association_data(association_name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def owner_update_hash(update)
|
|
63
|
+
{
|
|
64
|
+
ViewModel::ID_ATTRIBUTE => owner_viewmodel_id,
|
|
65
|
+
ViewModel::TYPE_ATTRIBUTE => owner_viewmodel.view_name,
|
|
66
|
+
association_name.to_s => update,
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def owner_viewmodel_id(required: true)
|
|
71
|
+
id_param_name = owner_viewmodel.view_name.downcase + '_id'
|
|
72
|
+
default = required ? {} : { default: nil }
|
|
73
|
+
parse_param(id_param_name, **default)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
included do
|
|
77
|
+
delegate :owner_viewmodel, :association_name, to: 'self.class'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class_methods do
|
|
81
|
+
attr_accessor :owner_viewmodel, :association_name
|
|
82
|
+
|
|
83
|
+
def nested_in(owner, as:)
|
|
84
|
+
unless owner.is_a?(Class) && owner < ViewModel::Record
|
|
85
|
+
owner = ViewModel::Registry.for_view_name(owner.to_s.camelize)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
self.owner_viewmodel = owner
|
|
89
|
+
raise ArgumentError.new("Could not find owner ViewModel class '#{owner_name}'") if owner_viewmodel.nil?
|
|
90
|
+
self.association_name = as
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Controller mixin for accessing a root ViewModel which can be accessed
|
|
4
|
+
# individually by a parent model. Enabled by calling `nested_in :parent, as:
|
|
5
|
+
# :child` on the viewmodel controller
|
|
6
|
+
|
|
7
|
+
# Contributes the following routes:
|
|
8
|
+
# POST /parents/:parent_id/child #create_associated -- deserialize (possibly existing) child, replacing existing child
|
|
9
|
+
# GET /parents/:parent_id/child #show_associated -- show child of parent
|
|
10
|
+
# DELETE /parents/:parent_id/child #destroy_associated -- delete relationship between parent and its child (possibly garbage-collecting child)
|
|
11
|
+
|
|
12
|
+
## Inherits the following routes to manipulate children directly:
|
|
13
|
+
# POST /children #create -- create or update without parent
|
|
14
|
+
# GET /children #index -- list all child models regardless of parent
|
|
15
|
+
# GET /children/:id #show
|
|
16
|
+
# DELETE /children/:id #destroy
|
|
17
|
+
|
|
18
|
+
require 'view_model/active_record/nested_controller_base'
|
|
19
|
+
module ViewModel::ActiveRecord::SingularNestedController
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
include ViewModel::ActiveRecord::NestedControllerBase
|
|
22
|
+
|
|
23
|
+
def show_associated(scope: nil, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
|
|
24
|
+
show_association(scope: scope, serialize_context: serialize_context)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
|
|
28
|
+
write_association(serialize_context: serialize_context, deserialize_context: deserialize_context)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def destroy_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
|
|
32
|
+
destroy_association(false, serialize_context: serialize_context, deserialize_context: deserialize_context)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Assembles an update operation tree from user input. Handles the interlinking
|
|
2
|
+
# and model of update operations, but does not handle the actual user data nor
|
|
3
|
+
# the mechanism by which it is applied to models.
|
|
4
|
+
class ViewModel::ActiveRecord
|
|
5
|
+
class UpdateContext
|
|
6
|
+
ReleaseEntry = Struct.new(:viewmodel, :association_data) do
|
|
7
|
+
def initialize(*)
|
|
8
|
+
super
|
|
9
|
+
@claimed = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def release!
|
|
13
|
+
model = viewmodel.model
|
|
14
|
+
case association_data.direct_reflection.options[:dependent]
|
|
15
|
+
when :delete
|
|
16
|
+
model.delete
|
|
17
|
+
when :destroy
|
|
18
|
+
model.destroy
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def claimed!
|
|
23
|
+
@claimed = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def claimed?
|
|
27
|
+
@claimed
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class ReleasePool
|
|
32
|
+
def initialize
|
|
33
|
+
# hash of { ViewModel::Reference => ReleaseEntry } for models
|
|
34
|
+
# that have been released by nodes we've already visited
|
|
35
|
+
@released_viewmodels = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def include?(key)
|
|
39
|
+
@released_viewmodels.has_key?(key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def release_to_pool(viewmodel, association_data)
|
|
43
|
+
@released_viewmodels[viewmodel.to_reference] =
|
|
44
|
+
ReleaseEntry.new(viewmodel, association_data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def claim_from_pool(key)
|
|
48
|
+
if (entry = @released_viewmodels.delete(key))
|
|
49
|
+
entry.claimed!
|
|
50
|
+
entry.viewmodel
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def release_all!
|
|
55
|
+
@released_viewmodels.each_value(&:release!)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.build!(root_update_data, referenced_update_data, root_type: nil)
|
|
60
|
+
if root_type.present? && (bad_types = root_update_data.map(&:viewmodel_class).to_set.delete(root_type)).present?
|
|
61
|
+
raise ViewModel::DeserializationError::InvalidViewType.new(root_type.view_name, bad_types.map { |t| ViewModel::Reference.new(t, nil) })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
self.new
|
|
65
|
+
.build_root_update_operations(root_update_data, referenced_update_data)
|
|
66
|
+
.assemble_update_tree
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# TODO an unfortunate abstraction violation. The `append` case constructs an
|
|
70
|
+
# update tree and later injects the context of parent and position.
|
|
71
|
+
def root_updates
|
|
72
|
+
@root_update_operations
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize
|
|
76
|
+
@root_update_operations = [] # The subject(s) of this update
|
|
77
|
+
@referenced_update_operations = {} # Shared data updates, referred to by a ref hash
|
|
78
|
+
|
|
79
|
+
# Set of ViewModel::Reference used to assert only a single update is
|
|
80
|
+
# present for each viewmodel
|
|
81
|
+
@updated_viewmodel_references = Set.new
|
|
82
|
+
|
|
83
|
+
# hash of { ViewModel::Reference => deferred UpdateOperation }
|
|
84
|
+
# for linked partially-constructed node updates
|
|
85
|
+
@worklist = {}
|
|
86
|
+
|
|
87
|
+
@release_pool = ReleasePool.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Processes parsed (UpdateData) root updates and referenced updates into
|
|
91
|
+
# @root_update_operations and @referenced_update_operations.
|
|
92
|
+
def build_root_update_operations(root_updates, referenced_updates)
|
|
93
|
+
# Look up viewmodel classes for each tree with eager_includes. Note this
|
|
94
|
+
# won't yet include through a polymorphic boundary: for now we become
|
|
95
|
+
# lazy-loading and slow every time that happens.
|
|
96
|
+
|
|
97
|
+
# Combine our root and referenced updates, and separate by viewmodel type
|
|
98
|
+
updates_by_viewmodel_class =
|
|
99
|
+
root_updates.lazily
|
|
100
|
+
.map { |root_update| [nil, root_update] }
|
|
101
|
+
.concat(referenced_updates)
|
|
102
|
+
.group_by { |_, update_data| update_data.viewmodel_class }
|
|
103
|
+
|
|
104
|
+
# For each viewmodel type, look up referenced models and construct viewmodels to update
|
|
105
|
+
updates_by_viewmodel_class.each do |viewmodel_class, updates|
|
|
106
|
+
dependencies = updates.map { |_, upd| upd.preload_dependencies }
|
|
107
|
+
.inject { |acc, deps| acc.merge!(deps) }
|
|
108
|
+
|
|
109
|
+
model_ids = updates.map { |_, update_data| update_data.id unless update_data.new? }.compact
|
|
110
|
+
|
|
111
|
+
existing_models =
|
|
112
|
+
if model_ids.present?
|
|
113
|
+
model_class = viewmodel_class.model_class
|
|
114
|
+
models = model_class.where(model_class.primary_key => model_ids).to_a
|
|
115
|
+
|
|
116
|
+
if models.size < model_ids.size
|
|
117
|
+
missing_model_ids = model_ids - models.map(&:id)
|
|
118
|
+
missing_viewmodel_refs = missing_model_ids.map { |id| ViewModel::Reference.new(viewmodel_class, id) }
|
|
119
|
+
raise ViewModel::DeserializationError::NotFound.new(missing_viewmodel_refs)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
DeepPreloader.preload(models, dependencies)
|
|
123
|
+
models.index_by(&:id)
|
|
124
|
+
else
|
|
125
|
+
{}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
updates.each do |ref, update_data|
|
|
129
|
+
viewmodel =
|
|
130
|
+
if update_data.new?
|
|
131
|
+
viewmodel_class.for_new_model(id: update_data.id)
|
|
132
|
+
else
|
|
133
|
+
viewmodel_class.new(existing_models[update_data.id])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
update_op = new_update(viewmodel, update_data)
|
|
137
|
+
|
|
138
|
+
if ref.nil?
|
|
139
|
+
@root_update_operations << update_op
|
|
140
|
+
else
|
|
141
|
+
# TODO make sure that referenced subtree hashes are unique and provide a decent error message
|
|
142
|
+
# not strictly necessary, but will save confusion
|
|
143
|
+
@referenced_update_operations[ref] = update_op
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
self
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Applies updates and subsequently releases. Returns the updated viewmodels.
|
|
152
|
+
def run!(deserialize_context:)
|
|
153
|
+
updated_viewmodels = @root_update_operations.map do |root_update|
|
|
154
|
+
root_update.run!(deserialize_context: deserialize_context)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@release_pool.release_all!
|
|
158
|
+
|
|
159
|
+
if updated_viewmodels.present?
|
|
160
|
+
# Deferred database constraints may have been violated by changes during
|
|
161
|
+
# deserialization. VM::AR promises that any errors during deserialization
|
|
162
|
+
# will be raised as a ViewModel::DeserializationError, so check constraints
|
|
163
|
+
# and raise before exit.
|
|
164
|
+
check_deferred_constraints!(updated_viewmodels.first.model.class)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
updated_viewmodels
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def assemble_update_tree
|
|
171
|
+
@root_update_operations.each do |root_update|
|
|
172
|
+
root_update.build!(self)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
while @worklist.present?
|
|
176
|
+
key = @worklist.keys.detect { |k| @release_pool.include?(k) }
|
|
177
|
+
if key.nil?
|
|
178
|
+
raise ViewModel::DeserializationError::ParentNotFound.new(@worklist.keys)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
deferred_update = @worklist.delete(key)
|
|
182
|
+
deferred_update.viewmodel = @release_pool.claim_from_pool(key)
|
|
183
|
+
deferred_update.build!(self)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
dangling_references = @referenced_update_operations.reject { |ref, upd| upd.built? }.map { |ref, upd| upd.viewmodel.to_reference }
|
|
187
|
+
if dangling_references.present?
|
|
188
|
+
raise ViewModel::DeserializationError::InvalidStructure.new("References not referred to from roots", dangling_references)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
self
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
## Methods for objects being built in this context
|
|
195
|
+
|
|
196
|
+
# We require the updates to be recorded in the context so we can enforce the
|
|
197
|
+
# property that each viewmodel is in the tree at most once. To avoid mistakes,
|
|
198
|
+
# we require construction to go via methods that do this tracking.
|
|
199
|
+
|
|
200
|
+
def new_deferred_update(viewmodel_reference, update_data, reparent_to: nil, reposition_to: nil)
|
|
201
|
+
update_operation = ViewModel::ActiveRecord::UpdateOperation.new(
|
|
202
|
+
nil, update_data, reparent_to: reparent_to, reposition_to: reposition_to)
|
|
203
|
+
check_unique_update!(viewmodel_reference)
|
|
204
|
+
@worklist[viewmodel_reference] = update_operation
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def new_update(viewmodel, update_data, reparent_to: nil, reposition_to: nil)
|
|
208
|
+
update = ViewModel::ActiveRecord::UpdateOperation.new(
|
|
209
|
+
viewmodel, update_data, reparent_to: reparent_to, reposition_to: reposition_to)
|
|
210
|
+
|
|
211
|
+
if (vm_ref = update.viewmodel_reference).present?
|
|
212
|
+
check_unique_update!(vm_ref)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
update
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def check_unique_update!(vm_ref)
|
|
219
|
+
unless @updated_viewmodel_references.add?(vm_ref)
|
|
220
|
+
raise ViewModel::DeserializationError::DuplicateNodes.new(vm_ref.viewmodel_class.view_name, vm_ref)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def resolve_reference(ref, blame_reference)
|
|
225
|
+
@referenced_update_operations.fetch(ref) do
|
|
226
|
+
raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def try_take_released_viewmodel(vm_ref)
|
|
231
|
+
@release_pool.claim_from_pool(vm_ref)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def release_viewmodel(viewmodel, association_data)
|
|
235
|
+
@release_pool.release_to_pool(viewmodel, association_data)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Immediately enforce any deferred database constraints (when using
|
|
239
|
+
# Postgres) and convert them to DeserializationErrors.
|
|
240
|
+
#
|
|
241
|
+
# Note that there's no effective way to tie such a failure back to the
|
|
242
|
+
# individual node that caused it, without attempting to parse Postgres'
|
|
243
|
+
# human-readable error details.
|
|
244
|
+
def check_deferred_constraints!(model_class)
|
|
245
|
+
if model_class.connection.adapter_name == "PostgreSQL"
|
|
246
|
+
model_class.connection.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
|
247
|
+
end
|
|
248
|
+
rescue ::ActiveRecord::StatementInvalid => ex
|
|
249
|
+
raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
require 'renum'
|
|
2
|
+
require 'view_model/schemas'
|
|
3
|
+
|
|
4
|
+
class ViewModel::ActiveRecord
|
|
5
|
+
using ViewModel::Utils::Collections
|
|
6
|
+
|
|
7
|
+
class FunctionalUpdate
|
|
8
|
+
def self.for_type(type)
|
|
9
|
+
case type
|
|
10
|
+
when Append::NAME
|
|
11
|
+
return Append
|
|
12
|
+
when Remove::NAME
|
|
13
|
+
return Remove
|
|
14
|
+
when Update::NAME
|
|
15
|
+
return Update
|
|
16
|
+
else
|
|
17
|
+
raise ArgumentError.new("invalid functional update type #{type}")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Append
|
|
22
|
+
NAME = 'append'
|
|
23
|
+
attr_accessor :before, :after
|
|
24
|
+
attr_reader :contents
|
|
25
|
+
def initialize(contents)
|
|
26
|
+
@contents = contents
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Update
|
|
31
|
+
NAME = 'update'
|
|
32
|
+
attr_reader :contents
|
|
33
|
+
def initialize(contents)
|
|
34
|
+
@contents = contents
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Remove
|
|
39
|
+
NAME = 'remove'
|
|
40
|
+
attr_reader :removed_vm_refs
|
|
41
|
+
|
|
42
|
+
def initialize(removed_vm_refs)
|
|
43
|
+
@removed_vm_refs = removed_vm_refs
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parser for collection updates. Collection updates have a regular
|
|
49
|
+
# structure, but vary based on the contents. Parsing a {direct,
|
|
50
|
+
# owned} collection recurses deeply and creates a tree of
|
|
51
|
+
# UpdateDatas, while parsing a {through, shared} collection collects
|
|
52
|
+
# reference strings.
|
|
53
|
+
class AbstractCollectionUpdate
|
|
54
|
+
# Wraps a complete collection of new data: either UpdateDatas for owned
|
|
55
|
+
# collections or reference strings for shared.
|
|
56
|
+
class Replace
|
|
57
|
+
attr_reader :contents
|
|
58
|
+
def initialize(contents)
|
|
59
|
+
@contents = contents
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Wraps an ordered list of FunctionalUpdates, each of whose `contents` are
|
|
64
|
+
# either UpdateData for owned collections or references for shared.
|
|
65
|
+
class Functional
|
|
66
|
+
attr_reader :actions
|
|
67
|
+
def initialize(actions)
|
|
68
|
+
@actions = actions
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def contents
|
|
72
|
+
actions.lazy
|
|
73
|
+
.reject { |action| action.is_a?(FunctionalUpdate::Remove) }
|
|
74
|
+
.flat_map(&:contents)
|
|
75
|
+
.to_a
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def vm_references(update_context)
|
|
79
|
+
used_vm_refs(update_context) + removed_vm_refs
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Resolve ViewModel::References used in the update's contents, whether by
|
|
83
|
+
# reference or value.
|
|
84
|
+
def used_vm_refs(update_context)
|
|
85
|
+
raise "abstract"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def removed_vm_refs
|
|
89
|
+
actions.lazy
|
|
90
|
+
.select { |action| action.is_a?(FunctionalUpdate::Remove) }
|
|
91
|
+
.flat_map(&:removed_vm_refs)
|
|
92
|
+
.to_a
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def check_for_duplicates!(update_context, blame)
|
|
96
|
+
duplicate_vm_refs = vm_references(update_context).duplicates
|
|
97
|
+
if duplicate_vm_refs.present?
|
|
98
|
+
formatted_invalid_ids = duplicate_vm_refs.keys.map(&:to_s).join(', ')
|
|
99
|
+
raise ViewModel::DeserializationError::InvalidStructure.new("Duplicate functional update targets: [#{formatted_invalid_ids}]", blame)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Parser
|
|
105
|
+
def initialize(association_data, blame_reference, valid_reference_keys)
|
|
106
|
+
@association_data = association_data
|
|
107
|
+
@blame_reference = blame_reference
|
|
108
|
+
@valid_reference_keys = valid_reference_keys
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse(value)
|
|
112
|
+
case value
|
|
113
|
+
when Array
|
|
114
|
+
replace_update_type.new(parse_contents(value))
|
|
115
|
+
|
|
116
|
+
when Hash
|
|
117
|
+
ViewModel::Schemas.verify_schema!(functional_update_schema, value)
|
|
118
|
+
functional_updates = value[ACTIONS_ATTRIBUTE].map { |action| parse_action(action) }
|
|
119
|
+
functional_update_type.new(functional_updates)
|
|
120
|
+
|
|
121
|
+
else
|
|
122
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
123
|
+
"Could not parse non-array value for collection association '#{association_data}'",
|
|
124
|
+
blame_reference)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
protected
|
|
129
|
+
|
|
130
|
+
attr_reader :association_data, :blame_reference, :valid_reference_keys
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def parse_action(action)
|
|
135
|
+
type = action[ViewModel::TYPE_ATTRIBUTE]
|
|
136
|
+
|
|
137
|
+
case type
|
|
138
|
+
when FunctionalUpdate::Remove::NAME
|
|
139
|
+
parse_remove_action(action)
|
|
140
|
+
when FunctionalUpdate::Append::NAME
|
|
141
|
+
parse_append_action(action)
|
|
142
|
+
when FunctionalUpdate::Update::NAME
|
|
143
|
+
parse_update_action(action)
|
|
144
|
+
else
|
|
145
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
146
|
+
"Unknown action type '#{type}'",
|
|
147
|
+
blame_reference)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
## Action parsers
|
|
152
|
+
#
|
|
153
|
+
# The shape of the actions are always the same
|
|
154
|
+
|
|
155
|
+
# Parse an anchor for a functional_update, before/after
|
|
156
|
+
# May only contain type and id fields, is never a reference even for shared collections.
|
|
157
|
+
def parse_anchor(child_hash) # final
|
|
158
|
+
child_metadata = ViewModel.extract_reference_only_metadata(child_hash)
|
|
159
|
+
|
|
160
|
+
child_viewmodel_class =
|
|
161
|
+
association_data.viewmodel_class_for_name(child_metadata.view_name)
|
|
162
|
+
|
|
163
|
+
if child_viewmodel_class.nil?
|
|
164
|
+
raise ViewModel::DeserializationError::InvalidAssociationType.new(
|
|
165
|
+
association_data.association_name.to_s,
|
|
166
|
+
child_metadata.view_name,
|
|
167
|
+
blame_reference)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
ViewModel::Reference.new(child_viewmodel_class, child_metadata.id)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def parse_append_action(action) # final
|
|
174
|
+
ViewModel::Schemas.verify_schema!(append_action_schema, action)
|
|
175
|
+
|
|
176
|
+
values = action[VALUES_ATTRIBUTE]
|
|
177
|
+
update = FunctionalUpdate::Append.new(parse_contents(values))
|
|
178
|
+
|
|
179
|
+
if (before = action[BEFORE_ATTRIBUTE])
|
|
180
|
+
update.before = parse_anchor(before)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if (after = action[AFTER_ATTRIBUTE])
|
|
184
|
+
update.after = parse_anchor(after)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if before && after
|
|
188
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
189
|
+
"Append may not specify both 'after' and 'before'",
|
|
190
|
+
blame_reference)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
update
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_update_action(action) # final
|
|
197
|
+
ViewModel::Schemas.verify_schema!(update_action_schema, action)
|
|
198
|
+
|
|
199
|
+
values = action[VALUES_ATTRIBUTE]
|
|
200
|
+
FunctionalUpdate::Update.new(parse_contents(values))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_remove_action(action) # final
|
|
204
|
+
ViewModel::Schemas.verify_schema!(remove_action_schema, action)
|
|
205
|
+
|
|
206
|
+
values = action[VALUES_ATTRIBUTE]
|
|
207
|
+
FunctionalUpdate::Remove.new(parse_remove_values(values))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
## Action contents
|
|
211
|
+
#
|
|
212
|
+
# The contents of the actions are determined by the subclasses
|
|
213
|
+
|
|
214
|
+
def functional_update_schema # abstract
|
|
215
|
+
raise "abstract"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def append_action_schema # abstract
|
|
219
|
+
raise "abstract"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def remove_action_schema # abstract
|
|
223
|
+
raise "abstract"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def update_action_schema # abstract
|
|
227
|
+
raise "abstract"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_contents(values) # abstract
|
|
231
|
+
raise "abstract"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Remove values are always anchors
|
|
235
|
+
def parse_remove_values(values)
|
|
236
|
+
# There's no reasonable interpretation of a remove update that includes data.
|
|
237
|
+
# Report it as soon as we detect it.
|
|
238
|
+
invalid_entries = values.reject { |h| UpdateData.reference_only_hash?(h) }
|
|
239
|
+
if invalid_entries.present?
|
|
240
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
241
|
+
"Removed entities must have only #{ViewModel::TYPE_ATTRIBUTE} and #{ViewModel::ID_ATTRIBUTE} fields. " \
|
|
242
|
+
"Invalid entries: #{invalid_entries}",
|
|
243
|
+
blame_reference)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
values.map { |value| parse_anchor(value) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
## Value constructors
|
|
250
|
+
#
|
|
251
|
+
# ReferencedCollectionUpdates and OwnedCollectionUpdates have different
|
|
252
|
+
# behaviour, so we parameterise the result type as well.
|
|
253
|
+
|
|
254
|
+
def replace_update_type # abstract
|
|
255
|
+
raise "abstract"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def functional_update_type # abstract
|
|
259
|
+
raise "abstract"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
class OwnedCollectionUpdate < AbstractCollectionUpdate
|
|
265
|
+
class Replace < AbstractCollectionUpdate::Replace
|
|
266
|
+
alias update_datas contents # as UpdateDatas
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
class Functional < AbstractCollectionUpdate::Functional
|
|
270
|
+
alias update_datas contents # as UpdateDatas
|
|
271
|
+
|
|
272
|
+
def used_vm_refs(update_context)
|
|
273
|
+
update_datas
|
|
274
|
+
.map { |upd| upd.viewmodel_reference if upd.id }
|
|
275
|
+
.compact
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
class Parser < AbstractCollectionUpdate::Parser
|
|
280
|
+
def functional_update_schema
|
|
281
|
+
UpdateData::Schemas::COLLECTION_UPDATE
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def append_action_schema
|
|
285
|
+
UpdateData::Schemas::APPEND_ACTION
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def remove_action_schema
|
|
289
|
+
UpdateData::Schemas::REMOVE_ACTION
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def update_action_schema
|
|
293
|
+
UpdateData::Schemas::UPDATE_ACTION
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def parse_contents(values)
|
|
297
|
+
values.map do |value|
|
|
298
|
+
UpdateData.parse_associated(association_data, blame_reference, valid_reference_keys, value)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def replace_update_type
|
|
303
|
+
Replace
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def functional_update_type
|
|
307
|
+
Functional
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class ReferencedCollectionUpdate < AbstractCollectionUpdate
|
|
313
|
+
class Replace < AbstractCollectionUpdate::Replace
|
|
314
|
+
alias references contents # as reference strings
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
class Functional < AbstractCollectionUpdate::Functional
|
|
318
|
+
alias references contents
|
|
319
|
+
|
|
320
|
+
def used_vm_refs(update_context)
|
|
321
|
+
references.map do |ref|
|
|
322
|
+
update_context.resolve_reference(ref, nil).viewmodel_reference
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
class Parser < AbstractCollectionUpdate::Parser
|
|
328
|
+
def functional_update_schema
|
|
329
|
+
UpdateData::Schemas::REFERENCED_COLLECTION_UPDATE
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def append_action_schema
|
|
333
|
+
UpdateData::Schemas::REFERENCED_APPEND_ACTION
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def remove_action_schema
|
|
337
|
+
UpdateData::Schemas::REFERENCED_REMOVE_ACTION
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def update_action_schema
|
|
341
|
+
UpdateData::Schemas::REFERENCED_UPDATE_ACTION
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def parse_contents(values)
|
|
345
|
+
invalid_entries = values.reject { |h| ref_hash?(h) }
|
|
346
|
+
|
|
347
|
+
if invalid_entries.present?
|
|
348
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
349
|
+
"Appended/Updated entities must be specified as '#{ViewModel::REFERENCE_ATTRIBUTE}' style hashes." \
|
|
350
|
+
"Invalid entries: #{invalid_entries}",
|
|
351
|
+
blame_reference)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
values.map do |x|
|
|
355
|
+
ref = ViewModel.extract_reference_metadata(x)
|
|
356
|
+
unless valid_reference_keys.include?(ref)
|
|
357
|
+
raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
|
|
358
|
+
end
|
|
359
|
+
ref
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private
|
|
364
|
+
|
|
365
|
+
def replace_update_type
|
|
366
|
+
Replace
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def functional_update_type
|
|
370
|
+
Functional
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def ref_hash?(value)
|
|
374
|
+
value.size == 1 && value.has_key?(ViewModel::REFERENCE_ATTRIBUTE)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
class UpdateData
|
|
380
|
+
attr_accessor :viewmodel_class, :metadata, :attributes, :associations, :referenced_associations
|
|
381
|
+
delegate :id, :view_name, :schema_version, to: :metadata
|
|
382
|
+
|
|
383
|
+
module Schemas
|
|
384
|
+
viewmodel_reference_only =
|
|
385
|
+
{
|
|
386
|
+
'type' => 'object',
|
|
387
|
+
'description' => 'viewmodel reference',
|
|
388
|
+
'properties' => { ViewModel::TYPE_ATTRIBUTE => { 'type' => 'string' },
|
|
389
|
+
ViewModel::ID_ATTRIBUTE => ViewModel::Schemas::ID_SCHEMA },
|
|
390
|
+
'additionalProperties' => false,
|
|
391
|
+
'required' => [ViewModel::TYPE_ATTRIBUTE, ViewModel::ID_ATTRIBUTE]
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
VIEWMODEL_REFERENCE_ONLY = JsonSchema.parse!(viewmodel_reference_only)
|
|
395
|
+
|
|
396
|
+
fupdate_base = ->(value_schema) do
|
|
397
|
+
{
|
|
398
|
+
'description' => 'functional update',
|
|
399
|
+
'type' => 'object',
|
|
400
|
+
'properties' => {
|
|
401
|
+
ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME,
|
|
402
|
+
FunctionalUpdate::Update::NAME,
|
|
403
|
+
FunctionalUpdate::Remove::NAME] },
|
|
404
|
+
VALUES_ATTRIBUTE => { 'type' => 'array',
|
|
405
|
+
'items' => value_schema }
|
|
406
|
+
},
|
|
407
|
+
'required' => [ViewModel::TYPE_ATTRIBUTE, VALUES_ATTRIBUTE]
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
append_mixin = {
|
|
412
|
+
'description' => 'collection append',
|
|
413
|
+
'additionalProperties' => false,
|
|
414
|
+
'properties' => {
|
|
415
|
+
ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME] },
|
|
416
|
+
BEFORE_ATTRIBUTE => viewmodel_reference_only,
|
|
417
|
+
AFTER_ATTRIBUTE => viewmodel_reference_only
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
fupdate_owned =
|
|
422
|
+
fupdate_base.(ViewModel::Schemas::VIEWMODEL_UPDATE_SCHEMA)
|
|
423
|
+
|
|
424
|
+
fupdate_shared =
|
|
425
|
+
fupdate_base.({ 'oneOf' => [ViewModel::Schemas::VIEWMODEL_REFERENCE_SCHEMA,
|
|
426
|
+
viewmodel_reference_only] })
|
|
427
|
+
|
|
428
|
+
# Referenced updates are special:
|
|
429
|
+
# - Append requires `_ref` hashes
|
|
430
|
+
# - Update requires `_ref` hashes
|
|
431
|
+
# - Remove requires vm refs (type/id)
|
|
432
|
+
# Checked in code (ReferencedCollectionUpdate::Builder.parse_*_values)
|
|
433
|
+
|
|
434
|
+
APPEND_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(append_mixin))
|
|
435
|
+
REFERENCED_APPEND_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(append_mixin))
|
|
436
|
+
|
|
437
|
+
update_mixin = {
|
|
438
|
+
'description' => 'collection update',
|
|
439
|
+
'additionalProperties' => false,
|
|
440
|
+
'properties' => {
|
|
441
|
+
ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Update::NAME] }
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
UPDATE_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(update_mixin))
|
|
446
|
+
REFERENCED_UPDATE_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(update_mixin))
|
|
447
|
+
|
|
448
|
+
remove_mixin = {
|
|
449
|
+
'description' => 'collection remove',
|
|
450
|
+
'additionalProperties' => false,
|
|
451
|
+
'properties' => {
|
|
452
|
+
ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Remove::NAME] },
|
|
453
|
+
# The VALUES_ATTRIBUTE should be a viewmodel_reference, but in the
|
|
454
|
+
# name of error messages, we allow more keys and check the
|
|
455
|
+
# constraint in code.
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
REMOVE_ACTION = JsonSchema.parse!(fupdate_owned.deep_merge(remove_mixin))
|
|
460
|
+
REFERENCED_REMOVE_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(remove_mixin))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
collection_update = ->(base_schema) do
|
|
464
|
+
{
|
|
465
|
+
'type' => 'object',
|
|
466
|
+
'description' => 'collection functional update',
|
|
467
|
+
'additionalProperties' => false,
|
|
468
|
+
'required' => [ViewModel::TYPE_ATTRIBUTE, ACTIONS_ATTRIBUTE],
|
|
469
|
+
'properties' => {
|
|
470
|
+
ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FUNCTIONAL_UPDATE_TYPE] },
|
|
471
|
+
ACTIONS_ATTRIBUTE => { 'type' => 'array', 'items' => base_schema }
|
|
472
|
+
# The ACTIONS_ATTRIBUTE could be accurately expressed as
|
|
473
|
+
#
|
|
474
|
+
# { 'oneOf' => [append, update, remove] }
|
|
475
|
+
#
|
|
476
|
+
# but this produces completely unusable error messages. Instead we
|
|
477
|
+
# specify it must be an array, and defer checking to the code that
|
|
478
|
+
# can determine the schema by inspecting the type field.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
COLLECTION_UPDATE = JsonSchema.parse!(collection_update.(fupdate_owned))
|
|
484
|
+
REFERENCED_COLLECTION_UPDATE = JsonSchema.parse!(collection_update.(fupdate_shared))
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def [](name)
|
|
488
|
+
case name
|
|
489
|
+
when :id
|
|
490
|
+
id
|
|
491
|
+
when :_type
|
|
492
|
+
viewmodel_class.view_name
|
|
493
|
+
else
|
|
494
|
+
attributes.fetch(name) { associations.fetch(name) { referenced_associations.fetch(name) }}
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def has_key?(name)
|
|
499
|
+
case name
|
|
500
|
+
when :id, :_type
|
|
501
|
+
true
|
|
502
|
+
else
|
|
503
|
+
attributes.has_key?(name) || associations.has_key?(name) || referenced_associations.has_key?(name)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def new?
|
|
508
|
+
id.nil? || metadata.new?
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def self.parse_hashes(root_subtree_hashes, referenced_subtree_hashes = {})
|
|
512
|
+
valid_reference_keys = referenced_subtree_hashes.keys.to_set
|
|
513
|
+
|
|
514
|
+
valid_reference_keys.each do |ref|
|
|
515
|
+
unless ref.is_a?(String)
|
|
516
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new("Invalid reference string: #{ref}")
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Construct root UpdateData
|
|
521
|
+
root_updates = root_subtree_hashes.map do |subtree_hash|
|
|
522
|
+
metadata = ViewModel.extract_viewmodel_metadata(subtree_hash)
|
|
523
|
+
viewmodel_class = ViewModel::Registry.for_view_name(metadata.view_name)
|
|
524
|
+
verify_schema_version!(viewmodel_class, metadata.schema_version, metadata.id) if metadata.schema_version
|
|
525
|
+
UpdateData.new(viewmodel_class, metadata, subtree_hash, valid_reference_keys)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Ensure that no root is referred to more than once
|
|
529
|
+
check_duplicate_updates(root_updates, type: "root")
|
|
530
|
+
|
|
531
|
+
# Construct reference UpdateData
|
|
532
|
+
referenced_updates = referenced_subtree_hashes.transform_values do |subtree_hash|
|
|
533
|
+
metadata = ViewModel.extract_viewmodel_metadata(subtree_hash)
|
|
534
|
+
viewmodel_class = ViewModel::Registry.for_view_name(metadata.view_name)
|
|
535
|
+
verify_schema_version!(viewmodel_class, metadata.schema_version, metadata.id) if metadata.schema_version
|
|
536
|
+
|
|
537
|
+
UpdateData.new(viewmodel_class, metadata, subtree_hash, valid_reference_keys)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
check_duplicate_updates(referenced_updates.values, type: "reference")
|
|
541
|
+
|
|
542
|
+
return root_updates, referenced_updates
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def self.check_duplicate_updates(updates, type:)
|
|
546
|
+
# Ensure that no root is referred to more than once
|
|
547
|
+
duplicates = updates.duplicates_by { |upd| upd.viewmodel_reference if upd.id }
|
|
548
|
+
if duplicates.present?
|
|
549
|
+
raise ViewModel::DeserializationError::DuplicateNodes.new(type, duplicates.keys)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def initialize(viewmodel_class, metadata, hash_data, valid_reference_keys)
|
|
554
|
+
self.viewmodel_class = viewmodel_class
|
|
555
|
+
self.metadata = metadata
|
|
556
|
+
self.attributes = {}
|
|
557
|
+
self.associations = {}
|
|
558
|
+
self.referenced_associations = {}
|
|
559
|
+
|
|
560
|
+
parse(hash_data, valid_reference_keys)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def self.empty_update_for(viewmodel)
|
|
564
|
+
metadata = ViewModel::Metadata.new(viewmodel.id, viewmodel.view_name, viewmodel.class.schema_version, false)
|
|
565
|
+
self.new(viewmodel.class, metadata, {}, [])
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Produce a sequence of update datas for a given association update value, in the spirit of Array.wrap.
|
|
569
|
+
def to_sequence(name, value)
|
|
570
|
+
association_data = self.viewmodel_class._association_data(name)
|
|
571
|
+
case
|
|
572
|
+
when value.nil?
|
|
573
|
+
[]
|
|
574
|
+
when association_data.shared?
|
|
575
|
+
[]
|
|
576
|
+
when association_data.collection? # not shared, because of shared? check above
|
|
577
|
+
value.update_datas
|
|
578
|
+
else
|
|
579
|
+
[value]
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Updates in terms of viewmodel associations
|
|
584
|
+
def updated_associations
|
|
585
|
+
deps = {}
|
|
586
|
+
|
|
587
|
+
(associations.merge(referenced_associations)).each do |assoc_name, assoc_update|
|
|
588
|
+
deps[assoc_name] =
|
|
589
|
+
to_sequence(assoc_name, assoc_update)
|
|
590
|
+
.each_with_object({}) do |update_data, updated_associations|
|
|
591
|
+
updated_associations.deep_merge!(update_data.updated_associations)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
deps
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def build_preload_specs(association_data, updates)
|
|
599
|
+
if association_data.polymorphic?
|
|
600
|
+
updates.map do |update_data|
|
|
601
|
+
target_model = update_data.viewmodel_class.model_class
|
|
602
|
+
DeepPreloader::PolymorphicSpec.new(
|
|
603
|
+
target_model.name => update_data.preload_dependencies)
|
|
604
|
+
end
|
|
605
|
+
else
|
|
606
|
+
updates.map { |update_data| update_data.preload_dependencies }
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def merge_preload_specs(association_data, specs)
|
|
611
|
+
empty = association_data.polymorphic? ? DeepPreloader::PolymorphicSpec.new : DeepPreloader::Spec.new
|
|
612
|
+
specs.inject(empty) { |acc, spec| acc.merge!(spec) }
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Updates in terms of activerecord associations: used for preloading subtree
|
|
616
|
+
# associations necessary to perform update.
|
|
617
|
+
def preload_dependencies
|
|
618
|
+
deps = {}
|
|
619
|
+
|
|
620
|
+
(associations.merge(referenced_associations)).each do |assoc_name, reference|
|
|
621
|
+
association_data = self.viewmodel_class._association_data(assoc_name)
|
|
622
|
+
|
|
623
|
+
preload_specs = build_preload_specs(association_data,
|
|
624
|
+
to_sequence(assoc_name, reference))
|
|
625
|
+
|
|
626
|
+
referenced_deps = merge_preload_specs(association_data, preload_specs)
|
|
627
|
+
|
|
628
|
+
if association_data.through?
|
|
629
|
+
referenced_deps = DeepPreloader::Spec.new(association_data.indirect_reflection.name.to_s => referenced_deps)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
deps[association_data.direct_reflection.name.to_s] = referenced_deps
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
DeepPreloader::Spec.new(deps)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def viewmodel_reference
|
|
639
|
+
ViewModel::Reference.new(viewmodel_class, id)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def self.parse_associated(association_data, blame_reference, valid_reference_keys, child_hash)
|
|
643
|
+
child_metadata = ViewModel.extract_viewmodel_metadata(child_hash)
|
|
644
|
+
|
|
645
|
+
child_viewmodel_class =
|
|
646
|
+
association_data.viewmodel_class_for_name(child_metadata.view_name)
|
|
647
|
+
|
|
648
|
+
if child_viewmodel_class.nil?
|
|
649
|
+
raise ViewModel::DeserializationError::InvalidAssociationType.new(
|
|
650
|
+
association_data.association_name.to_s,
|
|
651
|
+
child_metadata.view_name,
|
|
652
|
+
blame_reference)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
verify_schema_version!(child_viewmodel_class, child_metadata.schema_version, child_metadata.id) if child_metadata.schema_version
|
|
656
|
+
|
|
657
|
+
UpdateData.new(child_viewmodel_class, child_metadata, child_hash, valid_reference_keys)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
private
|
|
661
|
+
|
|
662
|
+
def self.reference_only_hash?(hash)
|
|
663
|
+
hash.size == 2 && hash.has_key?(ViewModel::ID_ATTRIBUTE) && hash.has_key?(ViewModel::TYPE_ATTRIBUTE)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def parse(hash_data, valid_reference_keys)
|
|
667
|
+
hash_data = hash_data.dup
|
|
668
|
+
|
|
669
|
+
# handle view pre-parsing if defined
|
|
670
|
+
self.viewmodel_class.pre_parse(viewmodel_reference, metadata, hash_data) if self.viewmodel_class.respond_to?(:pre_parse)
|
|
671
|
+
hash_data.keys.each do |key|
|
|
672
|
+
if self.viewmodel_class.respond_to?(:"pre_parse_#{key}")
|
|
673
|
+
self.viewmodel_class.public_send("pre_parse_#{key}", viewmodel_reference, metadata, hash_data, hash_data.delete(key))
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
hash_data.each do |name, value|
|
|
678
|
+
member_data = self.viewmodel_class._members[name]
|
|
679
|
+
case member_data
|
|
680
|
+
when ViewModel::Record::AttributeData
|
|
681
|
+
attributes[name] = value
|
|
682
|
+
|
|
683
|
+
when AssociationData
|
|
684
|
+
association_data = member_data
|
|
685
|
+
case
|
|
686
|
+
when value.nil?
|
|
687
|
+
if association_data.collection?
|
|
688
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
|
689
|
+
"Invalid collection update value 'nil' for association '#{name}'",
|
|
690
|
+
blame_reference)
|
|
691
|
+
end
|
|
692
|
+
if association_data.shared?
|
|
693
|
+
referenced_associations[name] = nil
|
|
694
|
+
else
|
|
695
|
+
associations[name] = nil
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
when association_data.through?
|
|
699
|
+
referenced_associations[name] =
|
|
700
|
+
ReferencedCollectionUpdate::Parser
|
|
701
|
+
.new(association_data, blame_reference, valid_reference_keys)
|
|
702
|
+
.parse(value)
|
|
703
|
+
|
|
704
|
+
when association_data.shared?
|
|
705
|
+
# Extract and check reference
|
|
706
|
+
ref = ViewModel.extract_reference_metadata(value)
|
|
707
|
+
|
|
708
|
+
unless valid_reference_keys.include?(ref)
|
|
709
|
+
raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
referenced_associations[name] = ref
|
|
713
|
+
|
|
714
|
+
else
|
|
715
|
+
if association_data.collection?
|
|
716
|
+
associations[name] =
|
|
717
|
+
OwnedCollectionUpdate::Parser
|
|
718
|
+
.new(association_data, blame_reference, valid_reference_keys)
|
|
719
|
+
.parse(value)
|
|
720
|
+
else # not a collection
|
|
721
|
+
associations[name] =
|
|
722
|
+
if value.nil?
|
|
723
|
+
nil
|
|
724
|
+
else
|
|
725
|
+
self.class.parse_associated(association_data, blame_reference, valid_reference_keys, value)
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
else
|
|
730
|
+
raise ViewModel::DeserializationError::UnknownAttribute.new(name, blame_reference)
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def blame_reference
|
|
737
|
+
ViewModel::Reference.new(self.viewmodel_class, self.id)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def self.verify_schema_version!(viewmodel_class, schema_version, id)
|
|
741
|
+
unless viewmodel_class.accepts_schema_version?(schema_version)
|
|
742
|
+
raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
|
|
743
|
+
viewmodel_class,
|
|
744
|
+
schema_version,
|
|
745
|
+
ViewModel::Reference.new(viewmodel_class, id))
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|