iknow_view_models 3.2.0 → 3.2.1
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 +4 -4
- data/.rubocop.yml +13 -0
- data/Appraisals +6 -6
- data/Rakefile +5 -5
- data/gemfiles/rails_5_2.gemfile +5 -5
- data/gemfiles/rails_6_0.gemfile +5 -5
- data/iknow_view_models.gemspec +40 -39
- data/lib/iknow_view_models.rb +9 -7
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model.rb +17 -14
- data/lib/view_model/access_control.rb +5 -2
- data/lib/view_model/access_control/composed.rb +10 -9
- data/lib/view_model/access_control/open.rb +2 -0
- data/lib/view_model/access_control/read_only.rb +2 -0
- data/lib/view_model/access_control/tree.rb +11 -6
- data/lib/view_model/access_control_error.rb +4 -1
- data/lib/view_model/active_record.rb +12 -11
- data/lib/view_model/active_record/association_data.rb +2 -1
- data/lib/view_model/active_record/association_manipulation.rb +6 -4
- data/lib/view_model/active_record/cache.rb +4 -2
- data/lib/view_model/active_record/collection_nested_controller.rb +3 -3
- data/lib/view_model/active_record/controller_base.rb +4 -1
- data/lib/view_model/active_record/nested_controller_base.rb +1 -0
- data/lib/view_model/active_record/update_context.rb +8 -6
- data/lib/view_model/active_record/update_data.rb +32 -30
- data/lib/view_model/active_record/update_operation.rb +17 -13
- data/lib/view_model/active_record/visitor.rb +0 -1
- data/lib/view_model/after_transaction_runner.rb +0 -1
- data/lib/view_model/callbacks.rb +3 -1
- data/lib/view_model/controller.rb +13 -3
- data/lib/view_model/deserialization_error.rb +15 -12
- data/lib/view_model/error.rb +12 -10
- data/lib/view_model/error_view.rb +3 -1
- data/lib/view_model/migration/no_path_error.rb +1 -0
- data/lib/view_model/migration/one_way_error.rb +1 -0
- data/lib/view_model/migration/unspecified_version_error.rb +1 -0
- data/lib/view_model/record.rb +11 -13
- data/lib/view_model/reference.rb +3 -1
- data/lib/view_model/references.rb +8 -5
- data/lib/view_model/registry.rb +1 -1
- data/lib/view_model/schemas.rb +9 -4
- data/lib/view_model/serialization_error.rb +4 -1
- data/lib/view_model/serialize_context.rb +4 -4
- data/lib/view_model/test_helpers.rb +8 -3
- data/lib/view_model/test_helpers/arvm_builder.rb +19 -14
- data/lib/view_model/traversal_context.rb +2 -1
- data/test/.rubocop.yml +14 -0
- data/test/helpers/arvm_test_models.rb +12 -9
- data/test/helpers/arvm_test_utilities.rb +5 -3
- data/test/helpers/controller_test_helpers.rb +31 -29
- data/test/helpers/match_enumerator.rb +1 -0
- data/test/helpers/query_logging.rb +2 -1
- data/test/helpers/test_access_control.rb +5 -3
- data/test/helpers/viewmodel_spec_helpers.rb +21 -20
- data/test/unit/view_model/access_control_test.rb +144 -144
- data/test/unit/view_model/active_record/alias_test.rb +15 -13
- data/test/unit/view_model/active_record/belongs_to_test.rb +40 -39
- data/test/unit/view_model/active_record/cache_test.rb +27 -26
- data/test/unit/view_model/active_record/cloner_test.rb +67 -63
- data/test/unit/view_model/active_record/controller_test.rb +37 -38
- data/test/unit/view_model/active_record/counter_test.rb +10 -9
- data/test/unit/view_model/active_record/customization_test.rb +59 -58
- data/test/unit/view_model/active_record/has_many_test.rb +112 -111
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +15 -14
- data/test/unit/view_model/active_record/has_many_through_test.rb +33 -38
- data/test/unit/view_model/active_record/has_one_test.rb +37 -36
- data/test/unit/view_model/active_record/migration_test.rb +13 -13
- data/test/unit/view_model/active_record/namespacing_test.rb +19 -17
- data/test/unit/view_model/active_record/poly_test.rb +44 -45
- data/test/unit/view_model/active_record/shared_test.rb +30 -28
- data/test/unit/view_model/active_record/version_test.rb +9 -7
- data/test/unit/view_model/active_record_test.rb +72 -72
- data/test/unit/view_model/callbacks_test.rb +19 -15
- data/test/unit/view_model/controller_test.rb +4 -2
- data/test/unit/view_model/record_test.rb +92 -97
- data/test/unit/view_model/traversal_context_test.rb +4 -5
- data/test/unit/view_model_test.rb +18 -16
- metadata +7 -5
@@ -1,4 +1,6 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'renum'
|
2
4
|
|
3
5
|
# Partially parsed tree of user-specified update hashes, created during deserialization.
|
4
6
|
class ViewModel::ActiveRecord
|
@@ -14,7 +16,7 @@ class ViewModel::ActiveRecord
|
|
14
16
|
:update_data,
|
15
17
|
:points_to, # AssociationData => UpdateOperation (returns single new viewmodel to update fkey)
|
16
18
|
:pointed_to, # AssociationData => UpdateOperation(s) (returns viewmodel(s) with which to update assoc cache)
|
17
|
-
:reparent_to,
|
19
|
+
:reparent_to, # If node needs to update its pointer to a new parent, ParentData for the parent
|
18
20
|
:reposition_to, # if this node participates in a list under its parent, what should its position be?
|
19
21
|
:released_children # Set of children that have been released
|
20
22
|
|
@@ -46,11 +48,11 @@ class ViewModel::ActiveRecord
|
|
46
48
|
|
47
49
|
# Evaluate a built update tree, applying and saving changes to the models.
|
48
50
|
def run!(deserialize_context:)
|
49
|
-
raise ViewModel::DeserializationError::Internal.new(
|
51
|
+
raise ViewModel::DeserializationError::Internal.new('Internal error: UpdateOperation run before build') unless built?
|
50
52
|
|
51
53
|
case @run_state
|
52
54
|
when RunState::Running
|
53
|
-
raise ViewModel::DeserializationError::Internal.new(
|
55
|
+
raise ViewModel::DeserializationError::Internal.new('Internal error: Cycle found in running UpdateOperation')
|
54
56
|
when RunState::Run
|
55
57
|
return viewmodel
|
56
58
|
end
|
@@ -217,7 +219,7 @@ class ViewModel::ActiveRecord
|
|
217
219
|
|
218
220
|
# Recursively builds UpdateOperations for the associations in our UpdateData
|
219
221
|
def build!(update_context)
|
220
|
-
raise ViewModel::DeserializationError::Internal.new(
|
222
|
+
raise ViewModel::DeserializationError::Internal.new('Internal error: UpdateOperation cannot build a deferred update') if viewmodel.nil?
|
221
223
|
return self if built?
|
222
224
|
|
223
225
|
update_data.associations.each do |association_name, association_update_data|
|
@@ -254,8 +256,8 @@ class ViewModel::ActiveRecord
|
|
254
256
|
def add_update(association_data, update)
|
255
257
|
target =
|
256
258
|
case association_data.pointer_location
|
257
|
-
when :remote
|
258
|
-
when :local
|
259
|
+
when :remote then pointed_to
|
260
|
+
when :local then points_to
|
259
261
|
end
|
260
262
|
|
261
263
|
target[association_data] = update
|
@@ -658,7 +660,7 @@ class ViewModel::ActiveRecord
|
|
658
660
|
other.indirect_viewmodel_reference == self.indirect_viewmodel_reference
|
659
661
|
end
|
660
662
|
|
661
|
-
alias
|
663
|
+
alias eql? ==
|
662
664
|
end
|
663
665
|
|
664
666
|
# Helper class to wrap the previous members of a referenced collection and
|
@@ -767,6 +769,7 @@ class ViewModel::ActiveRecord
|
|
767
769
|
member.ref_string = ref_string if ref_string
|
768
770
|
member
|
769
771
|
end
|
772
|
+
|
770
773
|
def remove_from_members(removed_members)
|
771
774
|
s = removed_members.to_set
|
772
775
|
members.reject! { |m| s.include?(m) }
|
@@ -900,11 +903,12 @@ class ViewModel::ActiveRecord
|
|
900
903
|
|
901
904
|
def clear_association_cache(model, reflection)
|
902
905
|
association = model.association(reflection.name)
|
903
|
-
|
904
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
906
|
+
association.target =
|
907
|
+
if reflection.collection?
|
908
|
+
[]
|
909
|
+
else
|
910
|
+
nil
|
911
|
+
end
|
908
912
|
end
|
909
913
|
|
910
914
|
def blame_reference
|
data/lib/view_model/callbacks.rb
CHANGED
@@ -79,7 +79,7 @@ module ViewModel::Callbacks
|
|
79
79
|
end
|
80
80
|
SRC
|
81
81
|
else
|
82
|
-
def self.create(callbacks, view, context)
|
82
|
+
def self.create(callbacks, view, context) # rubocop:disable Lint/NestedMethodDefinition
|
83
83
|
self.new(callbacks, view, context)
|
84
84
|
end
|
85
85
|
end
|
@@ -105,6 +105,7 @@ module ViewModel::Callbacks
|
|
105
105
|
define_singleton_method(:class_callbacks) { base_callbacks }
|
106
106
|
define_singleton_method(:all_callbacks) do |&block|
|
107
107
|
return to_enum(__method__) unless block
|
108
|
+
|
108
109
|
block.call(base_callbacks)
|
109
110
|
end
|
110
111
|
end
|
@@ -115,6 +116,7 @@ module ViewModel::Callbacks
|
|
115
116
|
subclass.define_singleton_method(:class_callbacks) { subclass_callbacks }
|
116
117
|
subclass.define_singleton_method(:all_callbacks) do |&block|
|
117
118
|
return to_enum(__method__) unless block
|
119
|
+
|
118
120
|
super(&block)
|
119
121
|
block.call(subclass_callbacks)
|
120
122
|
end
|
@@ -88,6 +88,7 @@ module ViewModel::Controller
|
|
88
88
|
if data.blank?
|
89
89
|
raise ViewModel::Error.new(status: 400, detail: "No data submitted: #{data.inspect}")
|
90
90
|
end
|
91
|
+
|
91
92
|
data.map { |el| _extract_param_hash(el) }
|
92
93
|
else
|
93
94
|
_extract_param_hash(data)
|
@@ -129,9 +130,18 @@ module ViewModel::Controller
|
|
129
130
|
# untouched. Requires a MultiJson adapter other than ActiveSupport's
|
130
131
|
# (modified) JsonGem.
|
131
132
|
class CompiledJson
|
132
|
-
def initialize(s)
|
133
|
-
|
134
|
-
|
133
|
+
def initialize(s)
|
134
|
+
@s = s
|
135
|
+
end
|
136
|
+
|
137
|
+
def to_json(*_args)
|
138
|
+
@s
|
139
|
+
end
|
140
|
+
|
141
|
+
def to_s
|
142
|
+
@s
|
143
|
+
end
|
144
|
+
|
135
145
|
undef_method :as_json
|
136
146
|
end
|
137
147
|
|
@@ -15,13 +15,14 @@ class ViewModel
|
|
15
15
|
unless nodes.all? { |n| n.viewmodel_class == first }
|
16
16
|
raise ArgumentError.new("All nodes must be of the same type for #{self.class.name}")
|
17
17
|
end
|
18
|
+
|
18
19
|
first
|
19
20
|
end
|
20
21
|
|
21
22
|
# A collection of DeserializationErrors
|
22
23
|
class Collection < ViewModel::AbstractErrorCollection
|
23
|
-
title
|
24
|
-
code
|
24
|
+
title 'Error(s) occurred during deserialization'
|
25
|
+
code 'DeserializationError.Collection'
|
25
26
|
|
26
27
|
def detail
|
27
28
|
"Error(s) occurred during deserialization: #{cause_details}"
|
@@ -33,7 +34,7 @@ class ViewModel
|
|
33
34
|
class InvalidRequest < DeserializationError
|
34
35
|
# Abstract
|
35
36
|
status 400
|
36
|
-
title
|
37
|
+
title 'Invalid request'
|
37
38
|
end
|
38
39
|
|
39
40
|
# There has been an unexpected internal failure of the ViewModel library.
|
@@ -145,6 +146,7 @@ class ViewModel
|
|
145
146
|
# association.
|
146
147
|
class InvalidAssociationType < InvalidRequest
|
147
148
|
attr_reader :association, :target_type
|
149
|
+
|
148
150
|
def initialize(association, target_type, node)
|
149
151
|
@association = association
|
150
152
|
@target_type = target_type
|
@@ -198,7 +200,7 @@ class ViewModel
|
|
198
200
|
end
|
199
201
|
|
200
202
|
def detail
|
201
|
-
errors = missing_nodes.map(&:to_s).join(
|
203
|
+
errors = missing_nodes.map(&:to_s).join(', ')
|
202
204
|
"Couldn't find requested member node(s) in association '#{association}': "\
|
203
205
|
"#{errors}"
|
204
206
|
end
|
@@ -218,7 +220,7 @@ class ViewModel
|
|
218
220
|
end
|
219
221
|
|
220
222
|
def detail
|
221
|
-
"Duplicate views for the same '#{type}' specified: "+ nodes.map(&:to_s).join(
|
223
|
+
"Duplicate views for the same '#{type}' specified: " + nodes.map(&:to_s).join(', ')
|
222
224
|
end
|
223
225
|
|
224
226
|
def meta
|
@@ -235,14 +237,14 @@ class ViewModel
|
|
235
237
|
end
|
236
238
|
|
237
239
|
def detail
|
238
|
-
"Multiple parents attempted to claim the same owned '#{association_name}' reference: " + nodes.map(&:to_s).join(
|
240
|
+
"Multiple parents attempted to claim the same owned '#{association_name}' reference: " + nodes.map(&:to_s).join(', ')
|
239
241
|
end
|
240
242
|
end
|
241
243
|
|
242
244
|
class ParentNotFound < NotFound
|
243
245
|
def detail
|
244
|
-
|
245
|
-
nodes.map(&:to_s).join(
|
246
|
+
'Could not resolve release from previous parent for the following owned viewmodel(s): ' +
|
247
|
+
nodes.map(&:to_s).join(', ')
|
246
248
|
end
|
247
249
|
end
|
248
250
|
|
@@ -284,7 +286,7 @@ class ViewModel
|
|
284
286
|
|
285
287
|
class ReadOnlyType < DeserializationError
|
286
288
|
status 400
|
287
|
-
detail
|
289
|
+
detail 'Deserialization not defined for view type'
|
288
290
|
end
|
289
291
|
|
290
292
|
class InvalidAttributeType < InvalidRequest
|
@@ -326,7 +328,7 @@ class ViewModel
|
|
326
328
|
status 400
|
327
329
|
|
328
330
|
def detail
|
329
|
-
errors = nodes.map(&:to_s).join(
|
331
|
+
errors = nodes.map(&:to_s).join(', ')
|
330
332
|
"Optimistic lock failure updating nodes: #{errors}"
|
331
333
|
end
|
332
334
|
end
|
@@ -408,8 +410,9 @@ class ViewModel
|
|
408
410
|
|
409
411
|
private
|
410
412
|
|
411
|
-
QUOTED_IDENTIFIER = /\A"(?:[^"]|"")+"
|
412
|
-
UNQUOTED_IDENTIFIER = /\A(?:\p{Alpha}|_)(?:\p{Alnum}|_)
|
413
|
+
QUOTED_IDENTIFIER = /\A"(?:[^"]|"")+"/.freeze
|
414
|
+
UNQUOTED_IDENTIFIER = /\A(?:\p{Alpha}|_)(?:\p{Alnum}|_)*/.freeze
|
415
|
+
|
413
416
|
def parse_identifier(stream)
|
414
417
|
if (identifier = stream.slice!(UNQUOTED_IDENTIFIER))
|
415
418
|
identifier
|
data/lib/view_model/error.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# Abstract base for renderable errors in ViewModel-based APIs. Errors of this
|
2
4
|
# type will be caught by ViewModel controllers and rendered in a standard format
|
3
5
|
# by ViewModel::ErrorView, which loosely follows errors in JSON-API.
|
@@ -6,7 +8,7 @@ class ViewModel::AbstractError < StandardError
|
|
6
8
|
# Brief DSL for quickly defining constant attribute values in subclasses
|
7
9
|
[:detail, :status, :title, :code].each do |attribute|
|
8
10
|
define_method(attribute) do |x|
|
9
|
-
define_method(attribute){ x }
|
11
|
+
define_method(attribute) { x }
|
10
12
|
end
|
11
13
|
end
|
12
14
|
end
|
@@ -25,7 +27,7 @@ class ViewModel::AbstractError < StandardError
|
|
25
27
|
|
26
28
|
# Human-readable reason for use displaying this error.
|
27
29
|
def detail
|
28
|
-
|
30
|
+
'ViewModel::AbstractError'
|
29
31
|
end
|
30
32
|
|
31
33
|
# HTTP status code most appropriate for this error
|
@@ -40,7 +42,7 @@ class ViewModel::AbstractError < StandardError
|
|
40
42
|
|
41
43
|
# Unique symbol identifying this error type
|
42
44
|
def code
|
43
|
-
|
45
|
+
'ViewModel.AbstractError'
|
44
46
|
end
|
45
47
|
|
46
48
|
# Additional information specific to this error type.
|
@@ -74,8 +76,6 @@ class ViewModel::AbstractError < StandardError
|
|
74
76
|
|
75
77
|
protected
|
76
78
|
|
77
|
-
|
78
|
-
|
79
79
|
def format_references(viewmodel_refs)
|
80
80
|
viewmodel_refs.map do |viewmodel_ref|
|
81
81
|
format_reference(viewmodel_ref)
|
@@ -85,7 +85,7 @@ class ViewModel::AbstractError < StandardError
|
|
85
85
|
def format_reference(viewmodel_ref)
|
86
86
|
{
|
87
87
|
ViewModel::TYPE_ATTRIBUTE => viewmodel_ref.viewmodel_class.view_name,
|
88
|
-
ViewModel::ID_ATTRIBUTE => viewmodel_ref.model_id
|
88
|
+
ViewModel::ID_ATTRIBUTE => viewmodel_ref.model_id,
|
89
89
|
}
|
90
90
|
end
|
91
91
|
end
|
@@ -100,12 +100,13 @@ class ViewModel::AbstractErrorWithBlame < ViewModel::AbstractError
|
|
100
100
|
unless @nodes.all? { |n| n.is_a?(ViewModel::Reference) }
|
101
101
|
raise ArgumentError.new("#{self.class.name}: 'blame_nodes' must all be of type ViewModel::Reference")
|
102
102
|
end
|
103
|
+
|
103
104
|
super()
|
104
105
|
end
|
105
106
|
|
106
107
|
def meta
|
107
108
|
{
|
108
|
-
nodes: format_references(nodes)
|
109
|
+
nodes: format_references(nodes),
|
109
110
|
}
|
110
111
|
end
|
111
112
|
end
|
@@ -117,8 +118,9 @@ class ViewModel::AbstractErrorCollection < ViewModel::AbstractError
|
|
117
118
|
def initialize(causes)
|
118
119
|
@causes = Array.wrap(causes)
|
119
120
|
unless @causes.present?
|
120
|
-
raise ArgumentError.new(
|
121
|
+
raise ArgumentError.new('A collection must have at least one cause')
|
121
122
|
end
|
123
|
+
|
122
124
|
super()
|
123
125
|
end
|
124
126
|
|
@@ -151,7 +153,7 @@ class ViewModel::AbstractErrorCollection < ViewModel::AbstractError
|
|
151
153
|
protected
|
152
154
|
|
153
155
|
def cause_details
|
154
|
-
causes.map(&:detail).join(
|
156
|
+
causes.map(&:detail).join('; ')
|
155
157
|
end
|
156
158
|
end
|
157
159
|
|
@@ -180,7 +182,7 @@ end
|
|
180
182
|
class ViewModel::Error < ViewModel::AbstractError
|
181
183
|
attr_reader :detail, :status, :title, :code, :meta
|
182
184
|
|
183
|
-
def initialize(status: 400, detail:
|
185
|
+
def initialize(status: 400, detail: 'ViewModel Error', title: nil, code: nil, meta: {})
|
184
186
|
@detail = detail
|
185
187
|
@status = status
|
186
188
|
@title = title
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'view_model/record'
|
2
4
|
|
3
5
|
# ViewModel for rendering ViewModel::AbstractErrors
|
@@ -10,7 +12,7 @@ class ViewModel::ErrorView < ViewModel::Record
|
|
10
12
|
def serialize_view(json, serialize_context: nil)
|
11
13
|
json.set! :class, exception.class.name
|
12
14
|
json.backtrace exception.backtrace
|
13
|
-
if cause = exception.cause
|
15
|
+
if (cause = exception.cause)
|
14
16
|
json.cause do
|
15
17
|
json.set! :class, cause.class.name
|
16
18
|
json.backtrace cause.backtrace
|
data/lib/view_model/record.rb
CHANGED
@@ -116,7 +116,7 @@ class ViewModel::Record < ViewModel
|
|
116
116
|
end
|
117
117
|
end
|
118
118
|
|
119
|
-
def resolve_viewmodel(
|
119
|
+
def resolve_viewmodel(_metadata, _view_hash, deserialize_context:)
|
120
120
|
self.for_new_model
|
121
121
|
end
|
122
122
|
|
@@ -181,6 +181,8 @@ class ViewModel::Record < ViewModel
|
|
181
181
|
@changed_attributes = []
|
182
182
|
@changed_nested_children = false
|
183
183
|
@changed_referenced_children = false
|
184
|
+
|
185
|
+
super()
|
184
186
|
end
|
185
187
|
|
186
188
|
# VM::Record identity matches the identity of its model. If the model has a
|
@@ -309,12 +311,10 @@ class ViewModel::Record < ViewModel
|
|
309
311
|
# viewmodel), it's only desired for converting the value to and from wire
|
310
312
|
# format, so conversion is deferred to serialization time.
|
311
313
|
value = attr_data.map_value(value) do |v|
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
"Could not serialize invalid value '#{vm_attr_name}': #{ex.message}")
|
317
|
-
end
|
314
|
+
attr_data.attribute_serializer.dump(v, json: true)
|
315
|
+
rescue IknowParams::Serializer::DumpError => ex
|
316
|
+
raise ViewModel::SerializationError.new(
|
317
|
+
"Could not serialize invalid value '#{vm_attr_name}': #{ex.message}")
|
318
318
|
end
|
319
319
|
end
|
320
320
|
|
@@ -342,12 +342,10 @@ class ViewModel::Record < ViewModel
|
|
342
342
|
end
|
343
343
|
when attr_data.using_serializer?
|
344
344
|
attr_data.map_value(serialized_value) do |sv|
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
raise ViewModel::DeserializationError::Validation.new(vm_attr_name, reason, {}, blame_reference)
|
350
|
-
end
|
345
|
+
attr_data.attribute_serializer.load(sv)
|
346
|
+
rescue IknowParams::Serializer::LoadError => ex
|
347
|
+
reason = "could not be deserialized because #{ex.message}"
|
348
|
+
raise ViewModel::DeserializationError::Validation.new(vm_attr_name, reason, {}, blame_reference)
|
351
349
|
end
|
352
350
|
else
|
353
351
|
serialized_value
|
data/lib/view_model/reference.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class ViewModel
|
2
4
|
# Key to identify a viewmodel with some kind of inherent ID (e.g. an ViewModel::ActiveRecord)
|
3
5
|
class Reference
|
@@ -22,7 +24,7 @@ class ViewModel
|
|
22
24
|
other.model_id == model_id
|
23
25
|
end
|
24
26
|
|
25
|
-
alias
|
27
|
+
alias eql? ==
|
26
28
|
|
27
29
|
def hash
|
28
30
|
[viewmodel_class, model_id].hash
|