iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
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