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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. metadata +490 -0
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel
4
+ class DeserializationError < ViewModel::AbstractErrorWithBlame
5
+ status 500
6
+
7
+ def code
8
+ "DeserializationError.#{self.class.name.demodulize}"
9
+ end
10
+
11
+ protected
12
+
13
+ def viewmodel_class
14
+ first = nodes.first.viewmodel_class
15
+ unless nodes.all? { |n| n.viewmodel_class == first }
16
+ raise ArgumentError.new("All nodes must be of the same type for #{self.class.name}")
17
+ end
18
+ first
19
+ end
20
+
21
+ # A collection of DeserializationErrors
22
+ class Collection < ViewModel::AbstractErrorCollection
23
+ title "Error(s) occurred during deserialization"
24
+ code "DeserializationError.Collection"
25
+
26
+ def detail
27
+ "Error(s) occurred during deserialization: #{cause_details}"
28
+ end
29
+ end
30
+
31
+ # The client has provided a syntactically or structurally incoherent
32
+ # request.
33
+ class InvalidRequest < DeserializationError
34
+ # Abstract
35
+ status 400
36
+ title "Invalid request"
37
+ end
38
+
39
+ # There has been an unexpected internal failure of the ViewModel library.
40
+ class Internal < DeserializationError
41
+ status 500
42
+ attr_reader :detail
43
+
44
+ def initialize(detail, nodes = [])
45
+ @detail = detail
46
+ super(nodes)
47
+ end
48
+ end
49
+
50
+ class InvalidStructure < InvalidRequest
51
+ attr_reader :detail
52
+
53
+ def initialize(detail, nodes = [])
54
+ @detail = detail
55
+ super(nodes)
56
+ end
57
+ end
58
+
59
+ class InvalidSyntax < InvalidRequest
60
+ attr_reader :detail
61
+
62
+ def initialize(detail, nodes = [])
63
+ @detail = detail
64
+ super(nodes)
65
+ end
66
+ end
67
+
68
+ # A view included a invalid shared reference
69
+ class InvalidSharedReference < InvalidRequest
70
+ attr_reader :reference
71
+
72
+ def initialize(reference, node)
73
+ @reference = reference
74
+ super([node])
75
+ end
76
+
77
+ def detail
78
+ "Could not find shared reference with key '#{reference}'"
79
+ end
80
+
81
+ def meta
82
+ super.merge(reference: reference)
83
+ end
84
+ end
85
+
86
+ # A view was of an unknown type
87
+ class UnknownView < InvalidRequest
88
+ attr_reader :type
89
+
90
+ def initialize(type)
91
+ @type = type
92
+ super([])
93
+ end
94
+
95
+ def detail
96
+ "ViewModel class for view name '#{type}' could not be found"
97
+ end
98
+
99
+ def meta
100
+ super.merge(type: type)
101
+ end
102
+ end
103
+
104
+ # A view included an unknown attribute
105
+ class UnknownAttribute < InvalidRequest
106
+ attr_reader :attribute
107
+
108
+ def initialize(attribute, node)
109
+ @attribute = attribute
110
+ super([node])
111
+ end
112
+
113
+ def detail
114
+ "Unknown attribute/association #{attribute} in viewmodel '#{viewmodel_class.view_name}'"
115
+ end
116
+
117
+ def meta
118
+ super.merge(attribute: attribute)
119
+ end
120
+ end
121
+
122
+ # A view included an unexpected schema version for the corresponding
123
+ # viewmodel.
124
+ class SchemaVersionMismatch < InvalidRequest
125
+ attr_reader :viewmodel_class, :schema_version
126
+
127
+ def initialize(viewmodel_class, schema_version, nodes)
128
+ @viewmodel_class = viewmodel_class
129
+ @schema_version = schema_version
130
+ super(nodes)
131
+ end
132
+
133
+ def detail
134
+ "Mismatched schema version for type #{viewmodel_class.view_name}, "\
135
+ "expected #{viewmodel_class.schema_version}, received #{schema_version}."
136
+ end
137
+
138
+ def meta
139
+ super.merge(expected: viewmodel_class.schema_version,
140
+ received: schema_version)
141
+ end
142
+ end
143
+
144
+ # The target of an association was not a valid view type for that
145
+ # association.
146
+ class InvalidAssociationType < InvalidRequest
147
+ attr_reader :association, :target_type
148
+ def initialize(association, target_type, node)
149
+ @association = association
150
+ @target_type = target_type
151
+ super([node])
152
+ end
153
+
154
+ def detail
155
+ "Invalid target viewmodel type '#{target_type}' for association '#{association}'"
156
+ end
157
+
158
+ def meta
159
+ super.merge(association: association,
160
+ target_type: target_type)
161
+ end
162
+ end
163
+
164
+ class InvalidViewType < InvalidRequest
165
+ attr_reader :expected_type
166
+
167
+ def initialize(expected_type, node)
168
+ @expected_type = expected_type
169
+ super(node)
170
+ end
171
+
172
+ def detail
173
+ "Cannot deserialize inappropriate view type, expected '#{expected_type}' or an alias"
174
+ end
175
+
176
+ def meta
177
+ super.merge(expected_type: expected_type)
178
+ end
179
+ end
180
+
181
+ # Attempted to load persisted viewmodels by id, but they were not available
182
+ class NotFound < DeserializationError
183
+ status 404
184
+
185
+ def detail
186
+ model_ids = nodes.map(&:model_id)
187
+ "Couldn't find #{viewmodel_class.view_name}(s) with id(s)=#{model_ids.inspect}"
188
+ end
189
+ end
190
+
191
+ class AssociatedNotFound < NotFound
192
+ attr_reader :missing_nodes, :association
193
+
194
+ def initialize(association, missing_nodes, blame_nodes)
195
+ @association = association
196
+ @missing_nodes = Array.wrap(missing_nodes)
197
+ super(blame_nodes)
198
+ end
199
+
200
+ def detail
201
+ errors = missing_nodes.map(&:to_s).join(", ")
202
+ "Couldn't find requested member node(s) in association '#{association}': "\
203
+ "#{errors}"
204
+ end
205
+
206
+ def meta
207
+ super.merge(association: association,
208
+ missing_nodes: format_references(missing_nodes))
209
+ end
210
+ end
211
+
212
+ class DuplicateNodes < InvalidRequest
213
+ attr_reader :type
214
+
215
+ def initialize(type, nodes)
216
+ @type = type
217
+ super(nodes)
218
+ end
219
+
220
+ def detail
221
+ "Duplicate views for the same '#{type}' specified: "+ nodes.map(&:to_s).join(", ")
222
+ end
223
+
224
+ def meta
225
+ super.merge(type: type)
226
+ end
227
+ end
228
+
229
+ class ParentNotFound < NotFound
230
+ def detail
231
+ "Could not resolve release from previous parent for the following owned viewmodel(s): " +
232
+ nodes.map(&:to_s).join(", ")
233
+ end
234
+ end
235
+
236
+ class ReadOnlyAttribute < DeserializationError
237
+ status 400
238
+ attr_reader :attribute
239
+
240
+ def initialize(attribute, node)
241
+ @attribute = attribute
242
+ super([node])
243
+ end
244
+
245
+ def detail
246
+ "Cannot edit read only attribute '#{attribute}'"
247
+ end
248
+
249
+ def meta
250
+ super.merge(attribute: attribute)
251
+ end
252
+ end
253
+
254
+ class ReadOnlyType < DeserializationError
255
+ status 400
256
+ detail "Deserialization not defined for view type"
257
+ end
258
+
259
+ class InvalidAttributeType < InvalidRequest
260
+ attr_reader :attribute, :expected_type, :provided_type
261
+
262
+ def initialize(attribute, expected_type, provided_type, node)
263
+ @attribute = attribute
264
+ @expected_type = expected_type
265
+ @provided_type = provided_type
266
+ super([node])
267
+ end
268
+
269
+ def detail
270
+ "Expected '#{attribute}' to be of type '#{expected_type}', was '#{provided_type}'"
271
+ end
272
+
273
+ def meta
274
+ super.merge(attribute: attribute,
275
+ expected_type: expected_type,
276
+ provided_type: provided_type)
277
+ end
278
+ end
279
+
280
+ class InvalidParentEdit < DeserializationError
281
+ def initialize(changes, node)
282
+ @changes = changes
283
+ super([node])
284
+ end
285
+
286
+ detail 'Illegal edit to parent during external association update'
287
+
288
+ def meta
289
+ super.merge(changes: @changes.to_h)
290
+ end
291
+ end
292
+
293
+ # Optimistic lock failure updating nodes
294
+ class LockFailure < DeserializationError
295
+ status 400
296
+
297
+ def detail
298
+ errors = nodes.map(&:to_s).join(", ")
299
+ "Optimistic lock failure updating nodes: #{errors}"
300
+ end
301
+ end
302
+
303
+ class DatabaseConstraint < DeserializationError
304
+ status 400
305
+ attr_reader :detail
306
+
307
+ def initialize(detail, nodes = [])
308
+ @detail = detail
309
+ super(nodes)
310
+ end
311
+
312
+ # Database constraint errors are pretty opaque and stringly typed. We can
313
+ # do our best to parse out what metadata we can from the error, and fall
314
+ # back when we can't.
315
+ def self.from_exception(exception, nodes = [])
316
+ case exception.cause
317
+ when PG::UniqueViolation
318
+ UniqueViolation.from_postgres_error(exception.cause, nodes)
319
+ else
320
+ self.new(exception.message, nodes)
321
+ end
322
+ end
323
+ end
324
+
325
+ class UniqueViolation < DeserializationError
326
+ status 400
327
+ attr_reader :detail, :constraint, :columns, :values
328
+
329
+ PG_ERROR_FIELD_CONSTRAINT_NAME = 'n'.ord # Not exposed in pg gem
330
+ def self.from_postgres_error(err, nodes)
331
+ result = err.result
332
+ constraint = result.error_field(PG_ERROR_FIELD_CONSTRAINT_NAME)
333
+ message_detail = result.error_field(PG::Result::PG_DIAG_MESSAGE_DETAIL)
334
+
335
+ columns, values = parse_message_detail(message_detail)
336
+
337
+ unless columns
338
+ # Couldn't parse the detail message, fall back on an unparsed error
339
+ return DatabaseConstraint.new(err.message, nodes)
340
+ end
341
+
342
+ self.new(err.message, constraint, columns, values, nodes)
343
+ end
344
+
345
+ class << self
346
+ DETAIL_PREFIX = 'Key ('
347
+ DETAIL_SUFFIX = ') already exists.'
348
+ DETAIL_INFIX = ')=('
349
+ def parse_message_detail(detail)
350
+ stream = detail.dup
351
+
352
+ return nil unless stream.delete_prefix!(DETAIL_PREFIX)
353
+ return nil unless stream.delete_suffix!(DETAIL_SUFFIX)
354
+
355
+ # The message should start with an identifier list: pop off identifier
356
+ # tokens while we can.
357
+ identifiers = []
358
+
359
+ identifier = parse_identifier(stream)
360
+ return nil unless identifier
361
+
362
+ identifiers << identifier
363
+
364
+ while stream.delete_prefix!(', ')
365
+ identifier = parse_identifier(stream)
366
+ return nil unless identifier
367
+
368
+ identifiers << identifier
369
+ end
370
+
371
+ # The message should now contain ")=(" followed by the (unparseable)
372
+ # value list.
373
+ return nil unless stream.delete_prefix!(DETAIL_INFIX)
374
+
375
+ [identifiers, stream]
376
+ end
377
+
378
+ private
379
+
380
+ QUOTED_IDENTIFIER = /\A"(?:[^"]|"")+"/
381
+ UNQUOTED_IDENTIFIER = /\A(?:\p{Alpha}|_)(?:\p{Alnum}|_)*/
382
+ def parse_identifier(stream)
383
+ if (identifier = stream.slice!(UNQUOTED_IDENTIFIER))
384
+ identifier
385
+ elsif (quoted_identifier = stream.slice!(QUOTED_IDENTIFIER))
386
+ quoted_identifier[1..-2].gsub('""', '"')
387
+ else
388
+ nil
389
+ end
390
+ end
391
+ end
392
+
393
+ def initialize(detail, constraint, columns, values, nodes = [])
394
+ @detail = detail
395
+ @constraint = constraint
396
+ @columns = columns
397
+ @values = values
398
+ super(nodes)
399
+ end
400
+
401
+ def meta
402
+ super.merge(constraint: @constraint, columns: @columns, values: @values)
403
+ end
404
+ end
405
+
406
+ class Validation < DeserializationError
407
+ status 400
408
+ attr_reader :attribute, :reason, :details
409
+
410
+ def initialize(attribute, reason, details, node)
411
+ @attribute = attribute
412
+ @reason = reason
413
+ @details = details
414
+ super([node])
415
+ end
416
+
417
+ def detail
418
+ "Validation failed: '#{attribute}' #{reason}"
419
+ end
420
+
421
+ def meta
422
+ super.merge(attribute: attribute, message: reason, details: details)
423
+ end
424
+
425
+ # Return Validation errors for each error in the the provided
426
+ # ActiveModel::Errors, wrapped in a Collection if necessary.
427
+ def self.from_active_model(errors, node)
428
+ causes = errors.messages.each_key.flat_map do |attr|
429
+ errors.messages[attr].zip(errors.details[attr]).map do |message, details|
430
+ self.new(attr.to_s, message, details, node)
431
+ end
432
+ end
433
+ Collection.for_errors(causes)
434
+ end
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,16 @@
1
+ require 'view_model/traversal_context'
2
+
3
+ class ViewModel::DeserializeContext < ViewModel::TraversalContext
4
+ class SharedContext < ViewModel::TraversalContext::SharedContext
5
+ # During deserialization, collects a tree of viewmodel association names that
6
+ # were updated. Used to ensure that updated associations are always included
7
+ # in response serialization after deserialization, even if hidden by default.
8
+ attr_accessor :updated_associations
9
+ end
10
+
11
+ def self.shared_context_class
12
+ SharedContext
13
+ end
14
+
15
+ delegate :updated_associations, :"updated_associations=", to: :shared_context
16
+ end