Class: ViewModel::ActiveRecord

Inherits:
Record show all
Includes:
AssociationManipulation
Defined in:
lib/view_model/active_record/update_context.rb,
lib/view_model/active_record.rb,
lib/view_model/active_record/update_data.rb,
lib/view_model/active_record/update_operation.rb

Overview

Partially parsed tree of user-specified update hashes, created during deserialization.

Defined Under Namespace

Modules: AssociationManipulation, CollectionNestedController, Controller, ControllerBase, NestedControllerBase, SingularNestedController Classes: AbstractCollectionUpdate, AssociationData, Cache, Cloner, FunctionalUpdate, OwnedCollectionUpdate, ReferencedCollectionUpdate, UpdateContext, UpdateData, UpdateOperation, Visitor

Constant Summary collapse

FUNCTIONAL_UPDATE_TYPE =

for functional updates

'_update'
ACTIONS_ATTRIBUTE =
'actions'
VALUES_ATTRIBUTE =
'values'
BEFORE_ATTRIBUTE =
'before'
AFTER_ATTRIBUTE =
'after'

Constants inherited from ViewModel

BULK_UPDATES_ATTRIBUTE, BULK_UPDATE_ATTRIBUTE, BULK_UPDATE_TYPE, ID_ATTRIBUTE, MIGRATED_ATTRIBUTE, NEW_ATTRIBUTE, REFERENCE_ATTRIBUTE, TYPE_ATTRIBUTE, VERSION_ATTRIBUTE

Class Attribute Summary collapse

Instance Attribute Summary collapse

Attributes inherited from Record

#changed_attributes, #model, #previous_changes

Class Method Summary collapse

Instance Method Summary collapse

Methods included from AssociationManipulation

#append_associated, #delete_associated, #load_associated, #replace_associated

Methods inherited from Record

#==, attribute, #attribute_changed!, #changed_nested_children?, #changed_referenced_children?, deserialize_members_from_view, for_new_model, #hash, #id, inherited, initialize_as_viewmodel_record, member_names, model_class, #model_is_new!, #nested_children_changed!, #new_model?, #referenced_children_changed!, resolve_viewmodel, #serialize_view, should_register?, #stable_id?, #validate!

Methods inherited from ViewModel

#==, accepts_schema_version?, add_view_alias, attribute, attributes, #blame_reference, deserialize_context_class, deserialize_members_from_view, encode_json, extract_reference_metadata, extract_reference_only_metadata, extract_viewmodel_metadata, #hash, #id, inherited, initialize_as_viewmodel, is_update_hash?, lock_attribute_inheritance, member_names, #model, new_deserialize_context, new_serialize_context, preload_for_serialization, #preload_for_serialization, root!, root?, schema_hash, schema_versions, #serialize, serialize, serialize_as_reference, serialize_context_class, serialize_from_cache, serialize_to_hash, #serialize_to_hash, #serialize_view, #stable_id?, #to_json, #to_reference, #validate!, #view_name

Constructor Details

#initializeActiveRecord

Returns a new instance of ActiveRecord.



264
265
266
267
268
# File 'lib/view_model/active_record.rb', line 264

def initialize(*)
  super
  model_is_new! if model.new_record?
  @changed_associations = []
end

Class Attribute Details

._list_attribute_nameObject (readonly)

Returns the value of attribute _list_attribute_name.



37
38
39
# File 'lib/view_model/active_record.rb', line 37

def _list_attribute_name
  @_list_attribute_name
end

Instance Attribute Details

#changed_associationsObject (readonly)

Returns the value of attribute changed_associations.



34
35
36
# File 'lib/view_model/active_record.rb', line 34

def changed_associations
  @changed_associations
end

Class Method Details

._association_data(association_name) ⇒ Object

internal

Raises:

  • (ArgumentError)


256
257
258
259
260
261
# File 'lib/view_model/active_record.rb', line 256

def _association_data(association_name)
  association_data = self._members[association_name.to_s]
  raise ArgumentError.new("Invalid association '#{association_name}'") unless association_data.is_a?(AssociationData)

  association_data
end

._list_member?Boolean

Returns:

  • (Boolean)


57
58
59
# File 'lib/view_model/active_record.rb', line 57

def _list_member?
  _list_attribute_name.present?
end

.acts_as_list(attr = :position) ⇒ Object

Specifies that the model backing this viewmodel is a member of an ‘acts_as_manual_list` collection.



43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/view_model/active_record.rb', line 43

def acts_as_list(attr = :position)
  @_list_attribute_name = attr

  @generated_accessor_module.module_eval do
    define_method('_list_attribute') do
      model.public_send(attr)
    end

    define_method('_list_attribute=') do |x|
      model.public_send(:"#{attr}=", x)
    end
  end
end

.association(association_name, as: nil, viewmodel: nil, viewmodels: nil, external: false, read_only: false, through: nil, through_order_attr: nil) ⇒ Object

Adds an association from the model to this viewmodel. The associated model will be recursively (de)serialized by its own viewmodel type, which will be inferred from the model name, or may be explicitly specified.

An association to a root viewmodel type will be serialized with an indirect reference, while a child viewmodel type will be directly nested.

  • as sets the name of the association in the viewmodel

  • viewmodel, viewmodels specifies the viewmodel(s) to use for the association

  • external indicates an association external to the view. Externalized associations are not included in (de)serializations of the parent, and must be independently manipulated using ‘AssociationManipulation`. External associations may only be made to root viewmodels.

  • through names an ActiveRecord association that will be used like an ActiveRecord has_many:through:.

  • through_order_attr the through model is ordered by the given attribute (only applies to when through is set).



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/view_model/active_record.rb', line 83

def association(association_name,
                as: nil,
                viewmodel: nil,
                viewmodels: nil,
                external: false,
                read_only: false,
                through: nil,
                through_order_attr: nil)

  vm_association_name = (as || association_name).to_s

  if through
    direct_association_name   = through
    indirect_association_name = association_name
  else
    direct_association_name   = association_name
    indirect_association_name = nil
  end

  target_viewmodels = Array.wrap(viewmodel || viewmodels)

  association_data = AssociationData.new(
    owner:                     self,
    association_name:          vm_association_name,
    direct_association_name:   direct_association_name,
    indirect_association_name: indirect_association_name,
    target_viewmodels:         target_viewmodels,
    external:                  external,
    read_only:                 read_only,
    through_order_attr:        through_order_attr)

  _members[vm_association_name] = association_data

  @generated_accessor_module.module_eval do
    define_method vm_association_name do
      _read_association(vm_association_name)
    end

    define_method :"serialize_#{vm_association_name}" do |json, serialize_context: self.class.new_serialize_context|
      _serialize_association(vm_association_name, json, serialize_context: serialize_context)
    end
  end
end

.associations(*assocs, **args) ⇒ Object

Specify multiple associations at once



128
129
130
# File 'lib/view_model/active_record.rb', line 128

def associations(*assocs, **args)
  assocs.each { |assoc| association(assoc, **args) }
end

.cacheable!(**opts) ⇒ Object



250
251
252
253
# File 'lib/view_model/active_record.rb', line 250

def cacheable!(**opts)
  include ViewModel::ActiveRecord::Cache::CacheableView
  create_viewmodel_cache!(**opts)
end

.deep_schema_version(include_referenced: true, include_external: true) ⇒ Object



242
243
244
245
246
247
248
# File 'lib/view_model/active_record.rb', line 242

def deep_schema_version(include_referenced: true, include_external: true)
  (@deep_schema_version ||= {})[[include_referenced, include_external]] ||=
    begin
      vms = dependent_viewmodels(include_referenced: include_referenced, include_external: include_external)
      ViewModel.schema_versions(vms).freeze
    end
end

.dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true) ⇒ Object



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/view_model/active_record.rb', line 224

def dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true)
  return if seen.include?(self)

  seen << self

  _members.each_value do |data|
    next unless data.is_a?(AssociationData)
    next unless include_referenced || !data.referenced?
    next unless include_external   || !data.external?

    data.viewmodel_classes.each do |vm|
      vm.dependent_viewmodels(seen, include_referenced: include_referenced, include_external: include_external)
    end
  end

  seen
end

.deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/view_model/active_record.rb', line 166

def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context)
  model_class.transaction do
    ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes|
      root_update_data, referenced_update_data = UpdateData.parse_hashes(subtree_hashes, references)

      _updated_viewmodels =
        UpdateContext
          .build!(root_update_data, referenced_update_data, root_type: self)
          .run!(deserialize_context: deserialize_context)
    end
  end
rescue ViewModel::DeserializationError => e
  if (new_error = customize_deserialization_error(e))
    raise new_error
  else
    raise
  end
end

.eager_includes(include_referenced: true, vm_path: []) ⇒ Object

Constructs a preload specification of the required models for serializing/deserializing this view. Cycles in the schema will be broken after two layers of eager loading.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/view_model/active_record.rb', line 188

def eager_includes(include_referenced: true, vm_path: [])
  association_specs = {}

  return nil if vm_path.count(self) > 2

  child_path = vm_path + [self]
  _members.each do |assoc_name, association_data|
    next unless association_data.is_a?(AssociationData)
    next if association_data.external?

    case
    when association_data.through?
      viewmodel = association_data.direct_viewmodel
      children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)

    when !include_referenced && association_data.referenced?
      children = nil # Load up to the root viewmodel, but no further

    when association_data.polymorphic?
      children_by_klass = {}
      association_data.viewmodel_classes.each do |vm_class|
        klass = vm_class.model_class.name
        children_by_klass[klass] = vm_class.eager_includes(include_referenced: include_referenced, vm_path: child_path)
      end
      children = DeepPreloader::PolymorphicSpec.new(children_by_klass)

    else
      viewmodel = association_data.viewmodel_class
      children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)
    end

    association_specs[association_data.direct_reflection.name.to_s] = children
  end
  DeepPreloader::Spec.new(association_specs)
end

.find(id_or_ids, scope: nil, lock: nil, eager_include: true) ⇒ Object

Load instances of the viewmodel by id(s)



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/view_model/active_record.rb', line 133

def find(id_or_ids, scope: nil, lock: nil, eager_include: true)
  find_scope = self.model_class.all
  find_scope = find_scope.order(:id).lock(lock) if lock
  find_scope = find_scope.merge(scope) if scope

  ViewModel::Utils.wrap_one_or_many(id_or_ids) do |ids|
    models = find_scope.where(id: ids).to_a

    if models.size < ids.size
      missing_ids = ids - models.map(&:id)
      if missing_ids.present?
        raise ViewModel::DeserializationError::NotFound.new(
                missing_ids.map { |id| ViewModel::Reference.new(self, id) })
      end
    end

    vms = models.map { |m| self.new(m) }
    ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
    vms
  end
end

.load(scope: nil, eager_include: true, lock: nil) ⇒ Object

Load instances of the viewmodel by scope TODO: is this too much of a encapsulation violation?



157
158
159
160
161
162
163
164
# File 'lib/view_model/active_record.rb', line 157

def load(scope: nil, eager_include: true, lock: nil)
  load_scope = self.model_class.all
  load_scope = load_scope.lock(lock) if lock
  load_scope = load_scope.merge(scope) if scope
  vms = load_scope.map { |model| self.new(model) }
  ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
  vms
end

.model_previously_new?(model) ⇒ Boolean

Rails 6.1 introduced “previously_new_record?”, but this library still supports activerecord >= 5.0. This is an approximation.

Returns:

  • (Boolean)


376
377
378
379
380
381
382
# File 'lib/view_model/active_record.rb', line 376

def self.model_previously_new?(model)
  if (id_changes = model.saved_change_to_id)
    old_id, _new_id = id_changes
    return true if old_id.nil?
  end
  false
end

Instance Method Details

#_read_association(association_name) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/view_model/active_record.rb', line 334

def _read_association(association_name)
  association_data = self.class._association_data(association_name)

  associated = model.public_send(association_data.direct_reflection.name)
  return nil if associated.nil?

  case
  when association_data.through?
    # associated here are join-table models; we need to get the far side out
    join_models = associated

    if association_data.ordered?
      attr = association_data.direct_viewmodel._list_attribute_name
      join_models = join_models.sort_by { |j| j[attr] }
    end

    join_models.map do |through_model|
      model = through_model.public_send(association_data.indirect_reflection.name)
      association_data.viewmodel_class_for_model!(model.class).new(model)
    end

  when association_data.collection?
    associated_viewmodels = associated.map do |x|
      associated_viewmodel_class = association_data.viewmodel_class_for_model!(x.class)
      associated_viewmodel_class.new(x)
    end

    # If any associated type is a list member, they must all be
    if association_data.ordered?
      associated_viewmodels.sort_by!(&:_list_attribute)
    end

    associated_viewmodels

  else
    associated_viewmodel_class = association_data.viewmodel_class_for_model!(associated.class)
    associated_viewmodel_class.new(associated)
  end
end

#_read_association_touched(association_name, touched_ids:) ⇒ Object

Helper to return entities that were part of the last deserialization. The interface is complex due to the data requirements, and the implementation is inefficient.

Intended to be used by replace_associated style methods which may touch very large collections that must not be returned fully. Since the collection is not being returned, order is also ignored.



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/view_model/active_record.rb', line 391

def _read_association_touched(association_name, touched_ids:)
  association_data = self.class._association_data(association_name)

  associated = model.public_send(association_data.direct_reflection.name)
  return nil if associated.nil?

  case
  when association_data.through?
    # associated here are join-table models; we need to get the far side out
    associated.map do |through_model|
      model = through_model.public_send(association_data.indirect_reflection.name)

      next unless self.class.model_previously_new?(through_model) || touched_ids.include?(model.id)

      association_data.viewmodel_class_for_model!(model.class).new(model)
    end.reject(&:nil?)
  when association_data.collection?
    associated.map do |model|
      next unless self.class.model_previously_new?(model) || touched_ids.include?(model.id)

      association_data.viewmodel_class_for_model!(model.class).new(model)
    end.reject(&:nil?)
  else
    # singleton always touched by definition
    model = associated
    association_data.viewmodel_class_for_model!(model.class).new(model)
  end
end

#_serialize_association(association_name, json, serialize_context:) ⇒ Object



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/view_model/active_record.rb', line 420

def _serialize_association(association_name, json, serialize_context:)
  associated = self.public_send(association_name)
  association_data = self.class._association_data(association_name)

  json.set! association_name do
    case
    when associated.nil?
      json.null!
    when association_data.referenced?
      if association_data.collection?
        json.array!(associated) do |target|
          self.class.serialize_as_reference(target, json, serialize_context: serialize_context)
        end
      else
        self.class.serialize_as_reference(associated, json, serialize_context: serialize_context)
      end
    else
      self.class.serialize(associated, json, serialize_context: serialize_context)
    end
  end
end

#association_changed!(association_name) ⇒ Object



299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/view_model/active_record.rb', line 299

def association_changed!(association_name)
  association_name = association_name.to_s

  association_data = self.class._association_data(association_name)

  if association_data.read_only?
    raise ViewModel::DeserializationError::ReadOnlyAssociation.new(association_name, blame_reference)
  end

  unless @changed_associations.include?(association_name)
    @changed_associations << association_name
  end
end

#associations_changed?Boolean

Returns:

  • (Boolean)


313
314
315
# File 'lib/view_model/active_record.rb', line 313

def associations_changed?
  @changed_associations.present?
end

#changesObject

Additionally pass ‘changed_associations` while constructing changes.



318
319
320
321
322
323
324
325
326
# File 'lib/view_model/active_record.rb', line 318

def changes
  ViewModel::Changes.new(
    new:                         new_model?,
    changed_attributes:          changed_attributes,
    changed_associations:        changed_associations,
    changed_nested_children:     changed_nested_children?,
    changed_referenced_children: changed_referenced_children?,
  )
end

#clear_changes!Object



328
329
330
331
332
# File 'lib/view_model/active_record.rb', line 328

def clear_changes!
  super.tap do
    @changed_associations = []
  end
end

#context_for_child(member_name, context:) ⇒ Object



442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/view_model/active_record.rb', line 442

def context_for_child(member_name, context:)
  # Synthetic viewmodels don't exist as far as the traversal context is
  # concerned: pass through the child context received from the parent
  return context if self.class.synthetic

  # associations to roots start a new tree
  member_data = self.class._members[member_name.to_s]
  if member_data.association? && member_data.referenced?
    return context.for_references
  end

  super
end

#destroy!(deserialize_context: self.class.new_deserialize_context) ⇒ Object



286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/view_model/active_record.rb', line 286

def destroy!(deserialize_context: self.class.new_deserialize_context)
  model_class.transaction do
    ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
      changes = ViewModel::Changes.new(deleted: true)
      deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: changes)
      hook_control.record_changes(changes)
      model.destroy!
    end
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::InvalidForeignKey, ::ActiveRecord::RecordNotSaved => e
    raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(e, self.blame_reference)
  end
end

#serialize_members(json, serialize_context: self.class.new_serialize_context) ⇒ Object



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/view_model/active_record.rb', line 270

def serialize_members(json, serialize_context: self.class.new_serialize_context)
  self.class._members.each do |member_name, member_data|
    next if member_data.association? && member_data.external?

    member_context =
      case member_data
      when AssociationData
        self.context_for_child(member_name, context: serialize_context)
      else
        serialize_context
      end

    self.public_send("serialize_#{member_name}", json, serialize_context: member_context)
  end
end