iknow_view_models 3.4.1 → 3.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87a056bba1e37b5593a5ad90365c5b761976529f1c5ab4074b26dd666060d57e
4
- data.tar.gz: 5a796e74204b2fb1b55d8f94bf95c347b3b80a65bf3d48036f7b6de06cba8cf2
3
+ metadata.gz: 0cb094f674e990ef9a67bbcb7b0ee260065356cdb6a56ee6e1b19582c320e96c
4
+ data.tar.gz: 5bb5c175e7fd30442794c2ff87687eb5dec132fa7da0d0128a1b012bd50131d6
5
5
  SHA512:
6
- metadata.gz: 1b7d484cb7ab2d0286bfc99460f029e8a9ccbb5edcc355cee42671e285f7ce2efe45946f2f7130d528b947fdb8e3bd41aa12dc007a049674b35d7a41b6f6b5be
7
- data.tar.gz: 7ab16b3a6ec5569704da7a5bdfccde6374d43770c53e1e78ceca961e7043400842c8e84e3614528eee3b8f7c81ccf884f2d0ffc24793ef5cc8328d96cee12152
6
+ metadata.gz: f196d4af404bcb91d88519e04ed2afcd7d73c590af4838e16b32b4f73171d20500a4a092cb76d693d7a683bf4ca6ad337d5e79a10a5233870f9c244acfffe969
7
+ data.tar.gz: bfdd726357dc50f72732498d112274301089ab82719e46d7c5d7bfc5414a6bcf3dc12459bb9dee6ffa9c174dea1c81e2a0499649d9bbe1d1618702bfad18ef04
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.name = 'iknow_view_models'
9
9
  spec.version = IknowViewModels::VERSION
10
10
  spec.authors = ['iKnow Team']
11
- spec.email = ['edge@iknow.jp']
11
+ spec.email = ['systems@iknow.jp']
12
12
  spec.summary = 'ViewModels provide a means of encapsulating a collection of related data and specifying its JSON serialization.'
13
13
  spec.description = ''
14
14
  spec.homepage = 'https://github.com/iknow/cerego_view_models'
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency 'activesupport', '>= 5.0'
26
26
 
27
27
  spec.add_dependency 'acts_as_manual_list'
28
- spec.add_dependency 'deep_preloader', '>= 1.0.1'
28
+ spec.add_dependency 'deep_preloader', '>= 1.0.2'
29
29
  spec.add_dependency 'iknow_cache'
30
30
  spec.add_dependency 'iknow_params', '~> 2.2.0'
31
31
  spec.add_dependency 'keyword_builder'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.4.1'
4
+ VERSION = '3.5.0'
5
5
  end
data/lib/view_model.rb CHANGED
@@ -12,6 +12,10 @@ class ViewModel
12
12
  VERSION_ATTRIBUTE = '_version'
13
13
  NEW_ATTRIBUTE = '_new'
14
14
 
15
+ BULK_UPDATE_TYPE = '_bulk_update'
16
+ BULK_UPDATES_ATTRIBUTE = 'updates'
17
+ BULK_UPDATE_ATTRIBUTE = 'update'
18
+
15
19
  # Migrations leave a metadata attribute _migrated on any views that they
16
20
  # alter. This attribute is accessible as metadata when deserializing migrated
17
21
  # input, and is included in the output serialization sent to clients.
@@ -27,6 +31,14 @@ class ViewModel
27
31
  attr_reader :view_aliases
28
32
  attr_writer :view_name
29
33
 
34
+ # Boolean to indicate if the viewmodel is synthetic. Synthetic
35
+ # viewmodels are nearly-invisible glue. They're full viewmodels,
36
+ # but do not participate in hooks or registration. For example, a
37
+ # join table connecting A and B through T has a synthetic
38
+ # viewmodel T to represent the join model, but the external
39
+ # interface is a relationship of A to a list of Bs.
40
+ attr_accessor :synthetic
41
+
30
42
  def inherited(subclass)
31
43
  super
32
44
  subclass.initialize_as_viewmodel
@@ -35,14 +35,9 @@ class ViewModel::ActiveRecord < ViewModel::Record
35
35
 
36
36
  class << self
37
37
  attr_reader :_list_attribute_name
38
- attr_accessor :synthetic
39
38
 
40
39
  delegate :transaction, to: :model_class
41
40
 
42
- def should_register?
43
- super && !synthetic
44
- end
45
-
46
41
  # Specifies that the model backing this viewmodel is a member of an
47
42
  # `acts_as_manual_list` collection.
48
43
  def acts_as_list(attr = :position)
@@ -368,6 +363,52 @@ class ViewModel::ActiveRecord < ViewModel::Record
368
363
  end
369
364
  end
370
365
 
366
+ # Rails 6.1 introduced "previously_new_record?", but this library still
367
+ # supports activerecord >= 5.0. This is an approximation.
368
+ def self.model_previously_new?(model)
369
+ if (id_changes = model.saved_change_to_id)
370
+ old_id, _new_id = id_changes
371
+ return true if old_id.nil?
372
+ end
373
+ false
374
+ end
375
+
376
+ # Helper to return entities that were part of the last deserialization. The
377
+ # interface is complex due to the data requirements, and the implementation is
378
+ # inefficient.
379
+ #
380
+ # Intended to be used by replace_associated style methods which may touch very
381
+ # large collections that must not be returned fully. Since the collection is
382
+ # not being returned, order is also ignored.
383
+ def _read_association_touched(association_name, touched_ids:)
384
+ association_data = self.class._association_data(association_name)
385
+
386
+ associated = model.public_send(association_data.direct_reflection.name)
387
+ return nil if associated.nil?
388
+
389
+ case
390
+ when association_data.through?
391
+ # associated here are join-table models; we need to get the far side out
392
+ associated.map do |through_model|
393
+ model = through_model.public_send(association_data.indirect_reflection.name)
394
+
395
+ next unless self.class.model_previously_new?(through_model) || touched_ids.include?(model.id)
396
+
397
+ association_data.viewmodel_class_for_model!(model.class).new(model)
398
+ end.reject(&:nil?)
399
+ when association_data.collection?
400
+ associated.map do |model|
401
+ next unless self.class.model_previously_new?(model) || touched_ids.include?(model.id)
402
+
403
+ association_data.viewmodel_class_for_model!(model.class).new(model)
404
+ end.reject(&:nil?)
405
+ else
406
+ # singleton always touched by definition
407
+ model = associated
408
+ association_data.viewmodel_class_for_model!(model.class).new(model)
409
+ end
410
+ end
411
+
371
412
  def _serialize_association(association_name, json, serialize_context:)
372
413
  associated = self.public_send(association_name)
373
414
  association_data = self.class._association_data(association_name)
@@ -127,7 +127,9 @@ class ViewModel::ActiveRecord::AssociationData
127
127
  end
128
128
 
129
129
  def polymorphic?
130
- target_reflection.polymorphic?
130
+ # STI polymorphism isn't shown on the association reflection, so in that
131
+ # case we have to infer it by having multiple target viewmodel types.
132
+ target_reflection.polymorphic? || viewmodel_classes.size > 1
131
133
  end
132
134
 
133
135
  # The side of the immediate association that holds the pointer.
@@ -3,6 +3,8 @@
3
3
  # Mix-in for VM::ActiveRecord providing direct manipulation of
4
4
  # directly-associated entities. Avoids loading entire collections.
5
5
  module ViewModel::ActiveRecord::AssociationManipulation
6
+ extend ActiveSupport::Concern
7
+
6
8
  def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
7
9
  association_data = self.class._association_data(association_name)
8
10
  direct_reflection = association_data.direct_reflection
@@ -16,7 +18,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
16
18
  associated_viewmodel = association_data.viewmodel_class
17
19
  direct_viewmodel = association_data.direct_viewmodel
18
20
  else
19
- raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
21
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
20
22
 
21
23
  associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
22
24
  direct_viewmodel = associated_viewmodel
@@ -49,49 +51,63 @@ module ViewModel::ActiveRecord::AssociationManipulation
49
51
  end
50
52
  end
51
53
 
52
- # Replace the current member(s) of an association with the provided hash(es).
54
+ # Replace the current member(s) of an association with the provided
55
+ # hash(es). Only mentioned member(s) will be returned.
56
+ #
57
+ # This interface deals with associations directly where reasonable,
58
+ # with the notable exception of referenced+shared associations. That
59
+ # is to say, that owned associations should be presented in the form
60
+ # of direct update hashes, regardless of their
61
+ # referencing. Reference and shared associations are excluded to
62
+ # ensure that the update hash for a shared entity is unique, and
63
+ # that edits may only be specified once.
53
64
  def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
54
- association_data = self.class._association_data(association_name)
55
-
56
- if association_data.referenced?
57
- is_fupdate =
58
- association_data.collection? &&
59
- update_hash.is_a?(Hash) &&
60
- update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
61
-
62
- if is_fupdate
63
- update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
64
- action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
65
- if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
66
- # Remove actions are always type/id refs; others need to be translated to proper refs
67
- next
68
- end
65
+ _updated_parent, changed_children =
66
+ self.class.replace_associated_bulk(
67
+ association_name,
68
+ { self.id => update_hash },
69
+ references: references,
70
+ deserialize_context: deserialize_context
71
+ ).first
72
+
73
+ changed_children
74
+ end
69
75
 
70
- association_references = convert_updates_to_references(
71
- action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
72
- key: "#{action_type_name}_#{i}")
73
- references.merge!(association_references)
74
- action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
75
- association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
76
- end
77
- else
78
- update_hash = ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
79
- association_references = convert_updates_to_references(sh, key: 'replace')
80
- references.merge!(association_references)
81
- association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
82
- end
76
+ class_methods do
77
+ # Replace the current member(s) of an association with the provided
78
+ # hash(es) for many viewmodels. Only mentioned members will be returned.
79
+ #
80
+ # This is an interim implementation that requires loading the contents of
81
+ # all collections into memory and filtering for the mentioned entities,
82
+ # even for functional updates. This is in contrast to append_associated,
83
+ # which only operates on the new entities.
84
+ def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context)
85
+ association_data = _association_data(association_name)
86
+
87
+ touched_ids = updates_by_parent_id.each_with_object({}) do |(parent_id, update_hash), acc|
88
+ acc[parent_id] =
89
+ mentioned_children(
90
+ update_hash,
91
+ references: references,
92
+ association_data: association_data,
93
+ ).to_set
83
94
  end
84
- end
85
95
 
86
- root_update_hash = {
87
- ViewModel::ID_ATTRIBUTE => self.id,
88
- ViewModel::TYPE_ATTRIBUTE => self.class.view_name,
89
- association_name.to_s => update_hash,
90
- }
96
+ root_update_hashes = updates_by_parent_id.map do |parent_id, update_hash|
97
+ {
98
+ ViewModel::ID_ATTRIBUTE => parent_id,
99
+ ViewModel::TYPE_ATTRIBUTE => view_name,
100
+ association_name.to_s => update_hash,
101
+ }
102
+ end
91
103
 
92
- root_update_viewmodel = self.class.deserialize_from_view(root_update_hash, references: references, deserialize_context: deserialize_context)
104
+ root_update_viewmodels = deserialize_from_view(
105
+ root_update_hashes, references: references, deserialize_context: deserialize_context)
93
106
 
94
- root_update_viewmodel._read_association(association_name)
107
+ root_update_viewmodels.each_with_object({}) do |updated, acc|
108
+ acc[updated] = updated._read_association_touched(association_name, touched_ids: touched_ids.fetch(updated.id))
109
+ end
110
+ end
95
111
  end
96
112
 
97
113
  # Create or update members of a associated collection. For an ordered
@@ -118,7 +134,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
118
134
  direct_viewmodel_class = association_data.direct_viewmodel
119
135
  root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
120
136
  else
121
- raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
137
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
122
138
 
123
139
  direct_viewmodel_class = association_data.viewmodel_class
124
140
  root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references)
@@ -242,7 +258,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
242
258
  direct_viewmodel = association_data.direct_viewmodel
243
259
  association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id)
244
260
  else
245
- raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
261
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic?
246
262
 
247
263
  # viewmodel type for current association: nil in case of empty polymorphic association
248
264
  direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
@@ -313,7 +329,10 @@ module ViewModel::ActiveRecord::AssociationManipulation
313
329
  indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
314
330
 
315
331
  # Convert associated update data to references
316
- indirect_references = convert_updates_to_references(indirect_update_data, key: 'indirect_append')
332
+ indirect_references =
333
+ self.class.convert_updates_to_references(
334
+ indirect_update_data, key: 'indirect_append')
335
+
317
336
  referenced_update_data.merge!(indirect_references)
318
337
 
319
338
  # Find any existing models for the direct association: need to re-use any
@@ -352,12 +371,6 @@ module ViewModel::ActiveRecord::AssociationManipulation
352
371
  return direct_update_data, referenced_update_data
353
372
  end
354
373
 
355
- def convert_updates_to_references(indirect_update_data, key:)
356
- indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
357
- indirect_references["__#{key}_ref_#{i}"] = update
358
- end
359
- end
360
-
361
374
  # TODO: this functionality could reasonably be extracted into `acts_as_manual_list`.
362
375
  def select_append_positions(association_data, position_attr, append_count, before:, after:)
363
376
  direct_reflection = association_data.direct_reflection
@@ -395,10 +408,134 @@ module ViewModel::ActiveRecord::AssociationManipulation
395
408
  def check_association_type!(association_data, type)
396
409
  if type && !association_data.accepts?(type)
397
410
  raise ViewModel::SerializationError.new(
398
- "Type error: association '#{direct_reflection.name}' can't refer to viewmodel #{type.view_name}")
411
+ "Type error: association '#{association_data.association_name}' can't refer to viewmodel #{type.view_name}")
399
412
  elsif association_data.polymorphic? && !type
400
413
  raise ViewModel::SerializationError.new(
401
- "Need to specify target viewmodel type for polymorphic association '#{direct_reflection.name}'")
414
+ "Need to specify target viewmodel type for polymorphic association '#{association_data.association_name}'")
415
+ end
416
+ end
417
+
418
+ class_methods do
419
+ def convert_updates_to_references(indirect_update_data, key:)
420
+ indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
421
+ indirect_references["__#{key}_ref_#{i}"] = update
422
+ end
423
+ end
424
+
425
+ def add_reference_indirection(update_hash, association_data:, references:, key:)
426
+ raise ArgumentError.new('Not a referenced association') unless association_data.referenced?
427
+
428
+ is_fupdate =
429
+ association_data.collection? &&
430
+ update_hash.is_a?(Hash) &&
431
+ update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
432
+
433
+ if is_fupdate
434
+ update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
435
+ action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
436
+ if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
437
+ # Remove actions are always type/id refs; others need to be translated to proper refs
438
+ next
439
+ end
440
+
441
+ association_references = convert_updates_to_references(
442
+ action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
443
+ key: "#{key}_#{action_type_name}_#{i}")
444
+ references.merge!(association_references)
445
+ action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
446
+ association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
447
+ end
448
+
449
+ update_hash
450
+ else
451
+ ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
452
+ association_references = convert_updates_to_references(sh, key: "#{key}_replace")
453
+ references.merge!(association_references)
454
+ association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
455
+ end
456
+ end
457
+ end
458
+
459
+ # Traverses literals and fupdates to return referenced children.
460
+ #
461
+ # Runs before the main parser, so must be defensive
462
+ def each_child_hash(assoc_update, association_data:)
463
+ return enum_for(__method__, assoc_update, association_data: association_data) unless block_given?
464
+
465
+ is_fupdate =
466
+ association_data.collection? &&
467
+ assoc_update.is_a?(Hash) &&
468
+ assoc_update[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
469
+
470
+ if is_fupdate
471
+ assoc_update.fetch(ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE).each do |action|
472
+ action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
473
+ if action_type_name.nil?
474
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
475
+ "Functional update missing '#{ViewModel::ActiveRecord::TYPE_ATTRIBUTE}'"
476
+ )
477
+ end
478
+
479
+ if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
480
+ # Remove actions are not considered children of the action.
481
+ next
482
+ end
483
+
484
+ values = action.fetch(ViewModel::ActiveRecord::VALUES_ATTRIBUTE) {
485
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
486
+ "Functional update missing '#{ViewModel::ActiveRecord::VALUES_ATTRIBUTE}'"
487
+ )
488
+ }
489
+ values.each { |x| yield x }
490
+ end
491
+ else
492
+ ViewModel::Utils.wrap_one_or_many(assoc_update) do |assoc_updates|
493
+ assoc_updates.each { |u| yield u }
494
+ end
495
+ end
496
+ end
497
+
498
+ # Collects the ids of children that are mentioned in the update data.
499
+ #
500
+ # Runs before the main parser, so must be defensive.
501
+ def mentioned_children(assoc_update, references:, association_data:)
502
+ return enum_for(__method__, assoc_update, references: references, association_data: association_data) unless block_given?
503
+
504
+ each_child_hash(assoc_update, association_data: association_data).each do |child_hash|
505
+ unless child_hash.is_a?(Hash)
506
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
507
+ "Expected update hash, received: #{child_hash}"
508
+ )
509
+ end
510
+
511
+ if association_data.referenced?
512
+ ref_handle = child_hash.fetch(ViewModel::REFERENCE_ATTRIBUTE) {
513
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
514
+ "Reference hash missing '#{ViewModel::REFERENCE_ATTRIBUTE}'"
515
+ )
516
+ }
517
+
518
+ ref_update_hash = references.fetch(ref_handle) {
519
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
520
+ "Reference '#{ref_handle}' does not exist in references"
521
+ )
522
+ }
523
+
524
+ unless ref_update_hash.is_a?(Hash)
525
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
526
+ "Expected update hash, received: #{child_hash}"
527
+ )
528
+ end
529
+
530
+ if (id = ref_update_hash[ViewModel::ID_ATTRIBUTE])
531
+ yield id
532
+ end
533
+ else
534
+ if (id = child_hash[ViewModel::ID_ATTRIBUTE])
535
+ yield id
536
+ end
537
+ end
538
+ end
402
539
  end
403
540
  end
404
541
  end
@@ -52,22 +52,23 @@ class ViewModel::ActiveRecord::Cloner
52
52
  new_associated = associated
53
53
  else
54
54
  # Otherwise descend into the child, and attach the result
55
- vm_class =
56
- case
57
- when association_data.through?
58
- # descend into the synthetic join table viewmodel
59
- association_data.direct_viewmodel
60
- when association_data.collection?
61
- association_data.viewmodel_class
62
- else
63
- association_data.viewmodel_class_for_model!(associated.class)
64
- end
55
+ build_vm = ->(model) do
56
+ vm_class =
57
+ if association_data.through?
58
+ # descend into the synthetic join table viewmodel
59
+ association_data.direct_viewmodel
60
+ else
61
+ association_data.viewmodel_class_for_model!(model.class)
62
+ end
63
+
64
+ vm_class.new(model)
65
+ end
65
66
 
66
67
  new_associated =
67
68
  if ViewModel::Utils.array_like?(associated)
68
- associated.map { |m| clone(vm_class.new(m)) }.compact
69
+ associated.map { |m| clone(build_vm.(m)) }.compact
69
70
  else
70
- clone(vm_class.new(associated))
71
+ clone(build_vm.(associated))
71
72
  end
72
73
  end
73
74
  end