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,191 @@
|
|
|
1
|
+
# Abstract base for renderable errors in ViewModel-based APIs. Errors of this
|
|
2
|
+
# type will be caught by ViewModel controllers and rendered in a standard format
|
|
3
|
+
# by ViewModel::ErrorView, which loosely follows errors in JSON-API.
|
|
4
|
+
class ViewModel::AbstractError < StandardError
|
|
5
|
+
class << self
|
|
6
|
+
# Brief DSL for quickly defining constant attribute values in subclasses
|
|
7
|
+
[:detail, :status, :title, :code].each do |attribute|
|
|
8
|
+
define_method(attribute) do |x|
|
|
9
|
+
define_method(attribute){ x }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
# `detail` is used to provide the exception message. However, it's not safe
|
|
16
|
+
# to just override StandardError's `message` or `to_s` to call `detail`,
|
|
17
|
+
# since some of Ruby's C implementation of Exceptions internally ignores
|
|
18
|
+
# these methods and fetches the invisible internal `idMesg` attribute
|
|
19
|
+
# instead. (!)
|
|
20
|
+
#
|
|
21
|
+
# This means that all fields necessary to derive the detail message must be
|
|
22
|
+
# initialized before calling super().
|
|
23
|
+
super(detail)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Human-readable reason for use displaying this error.
|
|
27
|
+
def detail
|
|
28
|
+
"ViewModel::AbstractError"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# HTTP status code most appropriate for this error
|
|
32
|
+
def status
|
|
33
|
+
500
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Human-readable title for displaying this error
|
|
37
|
+
def title
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Unique symbol identifying this error type
|
|
42
|
+
def code
|
|
43
|
+
"ViewModel.AbstractError"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Additional information specific to this error type.
|
|
47
|
+
def meta
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Some types of error may be aggregations over multiple causes
|
|
52
|
+
def aggregation?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# If so, the causes of this error (as AbstractErrors)
|
|
57
|
+
def causes
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The exception responsible for this error. In most cases, that should be this
|
|
62
|
+
# object, but sometimes an Error may be used to wrap an external exception.
|
|
63
|
+
def exception
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def view
|
|
68
|
+
ViewModel::ErrorView.new(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_s
|
|
72
|
+
detail
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_references(viewmodel_refs)
|
|
80
|
+
viewmodel_refs.map do |viewmodel_ref|
|
|
81
|
+
format_reference(viewmodel_ref)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def format_reference(viewmodel_ref)
|
|
86
|
+
{
|
|
87
|
+
ViewModel::TYPE_ATTRIBUTE => viewmodel_ref.viewmodel_class.view_name,
|
|
88
|
+
ViewModel::ID_ATTRIBUTE => viewmodel_ref.model_id
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# For errors associated with specific viewmodel nodes, include metadata
|
|
94
|
+
# describing the node to blame.
|
|
95
|
+
class ViewModel::AbstractErrorWithBlame < ViewModel::AbstractError
|
|
96
|
+
attr_reader :nodes
|
|
97
|
+
|
|
98
|
+
def initialize(blame_nodes)
|
|
99
|
+
@nodes = Array.wrap(blame_nodes)
|
|
100
|
+
unless @nodes.all? { |n| n.is_a?(ViewModel::Reference) }
|
|
101
|
+
raise ArgumentError.new("#{self.class.name}: 'blame_nodes' must all be of type ViewModel::Reference")
|
|
102
|
+
end
|
|
103
|
+
super()
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def meta
|
|
107
|
+
{
|
|
108
|
+
nodes: format_references(nodes)
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Abstract collection of errors
|
|
114
|
+
class ViewModel::AbstractErrorCollection < ViewModel::AbstractError
|
|
115
|
+
attr_reader :causes
|
|
116
|
+
|
|
117
|
+
def initialize(causes)
|
|
118
|
+
@causes = Array.wrap(causes)
|
|
119
|
+
unless @causes.present?
|
|
120
|
+
raise ArgumentError.new("A collection must have at least one cause")
|
|
121
|
+
end
|
|
122
|
+
super()
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def status
|
|
126
|
+
causes.inject(causes.first.status) do |status, cause|
|
|
127
|
+
if status == cause.status
|
|
128
|
+
status
|
|
129
|
+
else
|
|
130
|
+
400
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def detail
|
|
136
|
+
"ViewModel::AbstractErrors: #{cause_details}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def aggregation?
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.for_errors(errors)
|
|
144
|
+
if errors.size == 1
|
|
145
|
+
errors.first
|
|
146
|
+
else
|
|
147
|
+
self.new(errors)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
protected
|
|
152
|
+
|
|
153
|
+
def cause_details
|
|
154
|
+
causes.map(&:detail).join("; ")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Error type to wrap an arbitrary exception as a renderable ViewModel::AbstractError
|
|
159
|
+
class ViewModel::WrappedExceptionError < ViewModel::AbstractError
|
|
160
|
+
attr_reader :exception, :status
|
|
161
|
+
|
|
162
|
+
def initialize(exception, status, code)
|
|
163
|
+
@exception = exception
|
|
164
|
+
@status = status
|
|
165
|
+
@code = code
|
|
166
|
+
super()
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def detail
|
|
170
|
+
exception.message
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def code
|
|
174
|
+
@code || "Exception.#{exception.class.name}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Implementation of ViewModel::AbstractError with constructor parameters for
|
|
179
|
+
# each error data field.
|
|
180
|
+
class ViewModel::Error < ViewModel::AbstractError
|
|
181
|
+
attr_reader :detail, :status, :title, :code, :meta
|
|
182
|
+
|
|
183
|
+
def initialize(status: 400, detail: "ViewModel Error", title: nil, code: nil, meta: {})
|
|
184
|
+
@detail = detail
|
|
185
|
+
@status = status
|
|
186
|
+
@title = title
|
|
187
|
+
@code = code
|
|
188
|
+
@meta = meta
|
|
189
|
+
super()
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'view_model/record'
|
|
2
|
+
|
|
3
|
+
# ViewModel for rendering ViewModel::AbstractErrors
|
|
4
|
+
class ViewModel::ErrorView < ViewModel::Record
|
|
5
|
+
self.model_class = ViewModel::AbstractError
|
|
6
|
+
self.view_name = 'Error'
|
|
7
|
+
|
|
8
|
+
class ExceptionDetailView < ::ViewModel
|
|
9
|
+
attributes :exception
|
|
10
|
+
def serialize_view(json, serialize_context: nil)
|
|
11
|
+
json.set! :class, exception.class.name
|
|
12
|
+
json.backtrace exception.backtrace
|
|
13
|
+
if cause = exception.cause
|
|
14
|
+
json.cause do
|
|
15
|
+
json.set! :class, cause.class.name
|
|
16
|
+
json.backtrace cause.backtrace
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attributes :status, :detail, :title, :code, :meta
|
|
23
|
+
attribute :causes, array: true, using: self
|
|
24
|
+
attribute :exception, using: ExceptionDetailView
|
|
25
|
+
|
|
26
|
+
# Ruby exceptions should never be serialized in production
|
|
27
|
+
def serialize_exception(json, serialize_context:)
|
|
28
|
+
super if ViewModel::Config.show_cause_in_error_view
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Only serialize causes for aggregation errors.
|
|
32
|
+
def serialize_causes(*)
|
|
33
|
+
super if model.aggregation?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Abstract ViewModel type for serializing a subset of attributes from a record.
|
|
4
|
+
# A record viewmodel wraps a single underlying model, exposing a fixed set of
|
|
5
|
+
# real or calculated attributes.
|
|
6
|
+
class ViewModel::Record < ViewModel
|
|
7
|
+
# All ViewModel::Records have the same underlying ViewModel attribute: the
|
|
8
|
+
# record model they back on to. We want this to be inherited by subclasses, so
|
|
9
|
+
# we override ViewModel's :_attributes to close over it.
|
|
10
|
+
attr_accessor :model
|
|
11
|
+
|
|
12
|
+
require 'view_model/record/attribute_data'
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :_members
|
|
16
|
+
attr_accessor :abstract_class, :unregistered
|
|
17
|
+
|
|
18
|
+
def inherited(subclass)
|
|
19
|
+
super
|
|
20
|
+
subclass.initialize_as_viewmodel_record
|
|
21
|
+
ViewModel::Registry.register(subclass)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize_as_viewmodel_record
|
|
25
|
+
@_members = {}
|
|
26
|
+
@abstract_class = false
|
|
27
|
+
@unregistered = false
|
|
28
|
+
|
|
29
|
+
@generated_accessor_module = Module.new
|
|
30
|
+
include @generated_accessor_module
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Should this class be registered in the viewmodel registry
|
|
34
|
+
def should_register?
|
|
35
|
+
!abstract_class && !unregistered
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Specifies an attribute from the model to be serialized in this view
|
|
39
|
+
def attribute(attr, as: nil, read_only: false, write_once: false, using: nil, format: nil, array: false, optional: false)
|
|
40
|
+
model_attribute_name = attr.to_s
|
|
41
|
+
vm_attribute_name = (as || attr).to_s
|
|
42
|
+
|
|
43
|
+
if using && format
|
|
44
|
+
raise ArgumentError.new("Only one of :using and :format may be specified")
|
|
45
|
+
end
|
|
46
|
+
if using && !(using.is_a?(Class) && using < ViewModel)
|
|
47
|
+
raise ArgumentError.new("Invalid 'using:' viewmodel: not a viewmodel class")
|
|
48
|
+
end
|
|
49
|
+
if format && !format.respond_to?(:dump) && !format.respond_to?(:load)
|
|
50
|
+
raise ArgumentError.new("Invalid 'format:' serializer: must respond to :dump and :load")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_data = AttributeData.new(vm_attribute_name, model_attribute_name, using, format,
|
|
54
|
+
array, optional, read_only, write_once)
|
|
55
|
+
_members[vm_attribute_name] = attr_data
|
|
56
|
+
|
|
57
|
+
@generated_accessor_module.module_eval do
|
|
58
|
+
define_method vm_attribute_name do
|
|
59
|
+
_get_attribute(attr_data)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
define_method "serialize_#{vm_attribute_name}" do |json, serialize_context: self.class.new_serialize_context|
|
|
63
|
+
_serialize_attribute(attr_data, json, serialize_context: serialize_context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
define_method "deserialize_#{vm_attribute_name}" do |value, references: {}, deserialize_context: self.class.new_deserialize_context|
|
|
67
|
+
_deserialize_attribute(attr_data, value, references: references, deserialize_context: deserialize_context)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deserialize_from_view(view_hashes, references: {}, deserialize_context: new_deserialize_context)
|
|
73
|
+
ViewModel::Utils.map_one_or_many(view_hashes) do |view_hash|
|
|
74
|
+
view_hash = view_hash.dup
|
|
75
|
+
metadata = ViewModel.extract_viewmodel_metadata(view_hash)
|
|
76
|
+
|
|
77
|
+
unless self.view_name == metadata.view_name || self.view_aliases.include?(metadata.view_name)
|
|
78
|
+
raise ViewModel::DeserializationError::InvalidViewType.new(
|
|
79
|
+
self.view_name,
|
|
80
|
+
ViewModel::Reference.new(self, metadata.id))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if metadata.schema_version && !self.accepts_schema_version?(metadata.schema_version)
|
|
84
|
+
raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
|
|
85
|
+
self, version, ViewModel::Reference.new(self, metadata.id))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
viewmodel = resolve_viewmodel(metadata, view_hash, deserialize_context: deserialize_context)
|
|
89
|
+
|
|
90
|
+
deserialize_members_from_view(viewmodel, view_hash, references: references, deserialize_context: deserialize_context)
|
|
91
|
+
|
|
92
|
+
viewmodel
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:)
|
|
97
|
+
super do |hook_control|
|
|
98
|
+
final_changes = viewmodel.clear_changes!
|
|
99
|
+
|
|
100
|
+
if final_changes.changed?
|
|
101
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
hook_control.record_changes(final_changes)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_viewmodel(metadata, view_hash, deserialize_context:)
|
|
109
|
+
self.for_new_model
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def for_new_model(*model_args)
|
|
113
|
+
self.new(model_class.new(*model_args)).tap { |v| v.model_is_new! }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns the AR model class wrapped by this viewmodel. If this has not been
|
|
117
|
+
# set via `model_class_name=`, attempt to automatically resolve based on the
|
|
118
|
+
# name of this viewmodel.
|
|
119
|
+
def model_class
|
|
120
|
+
unless instance_variable_defined?(:@model_class)
|
|
121
|
+
# try to auto-detect the model class based on our name
|
|
122
|
+
self.model_class_name =
|
|
123
|
+
ViewModel::Registry.infer_model_class_name(self.view_name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@model_class
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def member_names
|
|
130
|
+
self._members.keys
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Set the record type to be wrapped by this viewmodel
|
|
136
|
+
def model_class_name=(name)
|
|
137
|
+
name = name.to_s
|
|
138
|
+
|
|
139
|
+
type = name.safe_constantize
|
|
140
|
+
|
|
141
|
+
if type.nil?
|
|
142
|
+
raise ArgumentError.new("Could not find model class with name '#{name}'")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
self.model_class = type
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Set the record type to be wrapped by this viewmodel
|
|
149
|
+
def model_class=(type)
|
|
150
|
+
if instance_variable_defined?(:@model_class)
|
|
151
|
+
raise ArgumentError.new("Model class for ViewModel '#{self.name}' already set")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@model_class = type
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
delegate :model_class, to: 'self.class'
|
|
159
|
+
|
|
160
|
+
attr_reader :changed_attributes, :previous_changes
|
|
161
|
+
|
|
162
|
+
def initialize(model)
|
|
163
|
+
unless model.is_a?(model_class)
|
|
164
|
+
raise ArgumentError.new("'#{model.inspect}' is not an instance of #{model_class.name}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
self.model = model
|
|
168
|
+
|
|
169
|
+
@new_model = false
|
|
170
|
+
@changed_attributes = []
|
|
171
|
+
@changed_children = false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# VM::Record identity matches the identity of its model. If the model has a
|
|
175
|
+
# stable identity, use it, otherwise fall back to its object_id.
|
|
176
|
+
def id
|
|
177
|
+
if stable_id?
|
|
178
|
+
model.id
|
|
179
|
+
else
|
|
180
|
+
model.object_id
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def stable_id?
|
|
185
|
+
model.respond_to?(:id)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def new_model?
|
|
189
|
+
@new_model
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def changed_children?
|
|
193
|
+
@changed_children
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def serialize_view(json, serialize_context: self.class.new_serialize_context)
|
|
197
|
+
json.set!(ViewModel::ID_ATTRIBUTE, model.id) if model.respond_to?(:id)
|
|
198
|
+
json.set!(ViewModel::TYPE_ATTRIBUTE, self.view_name)
|
|
199
|
+
json.set!(ViewModel::VERSION_ATTRIBUTE, self.class.schema_version)
|
|
200
|
+
|
|
201
|
+
serialize_members(json, serialize_context: serialize_context)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def serialize_members(json, serialize_context:)
|
|
205
|
+
self.class._members.each do |member_name, member_data|
|
|
206
|
+
next unless serialize_context.includes_member?(member_name, !member_data.optional?)
|
|
207
|
+
|
|
208
|
+
self.public_send("serialize_#{member_name}", json, serialize_context: serialize_context)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Check that the model backing this view is consistent, for example by calling
|
|
213
|
+
# AR validations. Default implementation handles ActiveModel::Validations, may
|
|
214
|
+
# be overridden by subclasses for other types of validation. Must raise
|
|
215
|
+
# DeserializationError::Validation if invalid.
|
|
216
|
+
def validate!
|
|
217
|
+
if model_class < ActiveModel::Validations && !model.valid?
|
|
218
|
+
raise ViewModel::DeserializationError::Validation.from_active_model(model.errors, self.blame_reference)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def model_is_new!
|
|
223
|
+
@new_model = true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def attribute_changed!(attr_name)
|
|
227
|
+
@changed_attributes << attr_name.to_s
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def children_changed!
|
|
231
|
+
@changed_children = true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def changes
|
|
235
|
+
ViewModel::Changes.new(
|
|
236
|
+
new: new_model?,
|
|
237
|
+
changed_attributes: changed_attributes,
|
|
238
|
+
changed_children: changed_children?)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def clear_changes!
|
|
242
|
+
@previous_changes = changes
|
|
243
|
+
@new_model = false
|
|
244
|
+
@changed_attributes = []
|
|
245
|
+
@changed_children = false
|
|
246
|
+
previous_changes
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Use ActiveRecord style identity for viewmodels. This allows serialization to
|
|
250
|
+
# generate a references section by keying on the viewmodel itself.
|
|
251
|
+
def hash
|
|
252
|
+
[self.class, self.model].hash
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def ==(other)
|
|
256
|
+
self.class == other.class && self.model == other.model
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
alias eql? ==
|
|
260
|
+
|
|
261
|
+
self.abstract_class = true
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def _get_attribute(attr_data)
|
|
266
|
+
value = model.public_send(attr_data.model_attr_name)
|
|
267
|
+
|
|
268
|
+
if attr_data.using_viewmodel? && !value.nil?
|
|
269
|
+
# Where an attribute uses a viewmodel, the associated viewmodel type is
|
|
270
|
+
# significant and may have behaviour: like with VM::ActiveRecord
|
|
271
|
+
# associations it's useful to return the value wrapped in its viewmodel
|
|
272
|
+
# type even when not serializing.
|
|
273
|
+
value = attr_data.map_value(value) do |v|
|
|
274
|
+
attr_data.attribute_viewmodel.new(v)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
value
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def _serialize_attribute(attr_data, json, serialize_context:)
|
|
282
|
+
vm_attr_name = attr_data.name
|
|
283
|
+
|
|
284
|
+
value = self.public_send(vm_attr_name)
|
|
285
|
+
|
|
286
|
+
if attr_data.using_serializer? && !value.nil?
|
|
287
|
+
# Where an attribute uses a low level serializer (rather than another
|
|
288
|
+
# viewmodel), it's only desired for converting the value to and from wire
|
|
289
|
+
# format, so conversion is deferred to serialization time.
|
|
290
|
+
value = attr_data.map_value(value) do |v|
|
|
291
|
+
begin
|
|
292
|
+
attr_data.attribute_serializer.dump(v, json: true)
|
|
293
|
+
rescue IknowParams::Serializer::DumpError => ex
|
|
294
|
+
raise ViewModel::SerializationError.new(
|
|
295
|
+
"Could not serialize invalid value '#{vm_attr_name}': #{ex.message}")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
json.set! vm_attr_name do
|
|
301
|
+
serialize_context = self.context_for_child(vm_attr_name, context: serialize_context) if attr_data.using_viewmodel?
|
|
302
|
+
self.class.serialize(value, json, serialize_context: serialize_context)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def _deserialize_attribute(attr_data, serialized_value, references:, deserialize_context:)
|
|
307
|
+
vm_attr_name = attr_data.name
|
|
308
|
+
|
|
309
|
+
if attr_data.array? && !serialized_value.nil?
|
|
310
|
+
expect_type!(vm_attr_name, Array, serialized_value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
value =
|
|
314
|
+
case
|
|
315
|
+
when serialized_value.nil?
|
|
316
|
+
serialized_value
|
|
317
|
+
when attr_data.using_viewmodel?
|
|
318
|
+
ctx = self.context_for_child(vm_attr_name, context: deserialize_context)
|
|
319
|
+
attr_data.map_value(serialized_value) do |sv|
|
|
320
|
+
attr_data.attribute_viewmodel.deserialize_from_view(sv, references: references, deserialize_context: ctx)
|
|
321
|
+
end
|
|
322
|
+
when attr_data.using_serializer?
|
|
323
|
+
attr_data.map_value(serialized_value) do |sv|
|
|
324
|
+
begin
|
|
325
|
+
attr_data.attribute_serializer.load(sv)
|
|
326
|
+
rescue IknowParams::Serializer::LoadError => ex
|
|
327
|
+
reason = "could not be deserialized because #{ex.message}"
|
|
328
|
+
raise ViewModel::DeserializationError::Validation.new(vm_attr_name, reason, {}, blame_reference)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
else
|
|
332
|
+
serialized_value
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Detect changes with ==. In the case of `using_viewmodel?`, this compares viewmodels or arrays of viewmodels.
|
|
336
|
+
if value != self.public_send(vm_attr_name)
|
|
337
|
+
if attr_data.read_only? && !(attr_data.write_once? && new_model?)
|
|
338
|
+
raise ViewModel::DeserializationError::ReadOnlyAttribute.new(vm_attr_name, blame_reference)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
attribute_changed!(vm_attr_name)
|
|
342
|
+
|
|
343
|
+
if attr_data.using_viewmodel? && !value.nil?
|
|
344
|
+
# Extract model from target viewmodel(s) to attach to our model
|
|
345
|
+
value = attr_data.map_value(value) { |vm| vm.model }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
model.public_send("#{attr_data.model_attr_name}=", value)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if attr_data.using_viewmodel? &&
|
|
352
|
+
Array.wrap(value).any? { |v| v.respond_to?(:previous_changes) && v.previous_changes.changed_tree? }
|
|
353
|
+
self.children_changed!
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Helper for type-checking input in hand-rolled deserialization: raises
|
|
358
|
+
# DeserializationError unless the serialized value is of the provided type.
|
|
359
|
+
def expect_type!(attribute, type, serialized_value)
|
|
360
|
+
unless serialized_value.is_a?(type)
|
|
361
|
+
raise ViewModel::DeserializationError::InvalidAttributeType.new(attribute.to_s,
|
|
362
|
+
type.name,
|
|
363
|
+
serialized_value.class.name,
|
|
364
|
+
blame_reference)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|